- Add Apollo Client for Saleor GraphQL API - Create GraphQL fragments (Product, Variant, Checkout) - Create GraphQL queries (Products, Checkout) - Create GraphQL mutations (Checkout operations) - Add TypeScript types for Saleor entities - Add product helper functions - Install @apollo/client and graphql dependencies Part of WordPress/WooCommerce → Saleor migration
15 KiB
15 KiB
Saleor Product Migration Guide
Overview
Guide for migrating products from WooCommerce to Saleor while maintaining identical URLs and supporting Serbian/English translations.
URL Structure Comparison
| Platform | Product URL Pattern |
|---|---|
| WooCommerce | /product/product-name/ |
| Target Structure | /products/organsko-maslinovo-ulje/ (Serbian) /products/organic-olive-oil/ (English) |
| Saleor API | No URLs - GraphQL only |
| Saleor Storefront | Configurable via Next.js routing |
Important: Saleor is Headless
Saleor itself has no URLs - it's just a GraphQL API. The URLs are determined by your storefront (Next.js/React app).
Current setup:
dev.manoonoils.com→ Next.js Storefront (currently using WooCommerce)api.manoonoils.com→ Saleor API (headless)dashboard.manoonoils.com→ Saleor Admin
URL Structure: /products/ with Different Slugs per Language
Target URL structure (no language prefix):
/products/organsko-maslinovo-ulje-500ml/ ← Serbian
/products/organic-olive-oil-500ml/ ← English (different slug)
Both URLs work independently - user switches language by clicking a language selector that navigates to the translated slug.
Database Setup
1. Serbian product (default):
INSERT INTO product_product (
name, slug, description, description_plaintext,
product_type_id, seo_title, seo_description
) VALUES (
'Organsko Maslinovo Ulje 500ml',
'organsko-maslinovo-ulje-500ml', -- Serbian slug
'{"blocks": [{"type": "paragraph", "data": {"text": "Opis na srpskom"}}]}',
'Opis na srpskom',
1, 'Organsko Maslinovo Ulje', 'Najbolje organsko ulje'
);
2. English translation with different slug:
-- Note: Different slug for English version
INSERT INTO product_producttranslation (
product_id, language_code, name, slug,
description, seo_title, seo_description
) VALUES (
1, 'en',
'Organic Olive Oil 500ml',
'organic-olive-oil-500ml', -- English slug (different!)
'{"blocks": [{"type": "paragraph", "data": {"text": "English description"}}]}',
'Organic Olive Oil', 'Best organic olive oil'
);
Next.js Storefront Configuration
next.config.js:
module.exports = {
// Disable Next.js i18n routing - we handle it manually
i18n: {
locales: ['default'],
defaultLocale: 'default',
localeDetection: false,
},
async rewrites() {
return [
// Handle /products/ prefix
{
source: '/products/:slug*',
destination: '/products/:slug*',
},
];
},
};
pages/products/[slug].tsx:
import { GetStaticProps, GetStaticPaths } from 'next';
import { useRouter } from 'next/router';
import { gql } from '@apollo/client';
import { saleorClient } from '@/lib/saleor/client';
const GET_PRODUCT_BY_SLUG = gql`
query GetProductBySlug($slug: String!) {
product(slug: $slug, channel: "default-channel") {
id
name
slug
description
translation(languageCode: EN) {
name
slug
description
}
}
}
`;
export const getStaticPaths: GetStaticPaths = async () => {
// Fetch ALL product slugs (both Serbian and English)
const { data } = await saleorClient.query({
query: gql`
query GetAllProductSlugs {
products(first: 100, channel: "default-channel") {
edges {
node {
slug # Serbian slug
translation(languageCode: EN) {
slug # English slug
}
}
}
}
}
`,
});
const paths = [];
data.products.edges.forEach(({ node }: any) => {
// Serbian slug
paths.push({ params: { slug: node.slug } });
// English slug (if exists)
if (node.translation?.slug) {
paths.push({ params: { slug: node.translation.slug } });
}
});
return {
paths,
fallback: 'blocking',
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const slug = params?.slug as string;
// Try to fetch product by this slug
const { data } = await saleorClient.query({
query: GET_PRODUCT_BY_SLUG,
variables: { slug },
});
if (!data.product) {
return { notFound: true };
}
// Determine language based on which slug matched
const isEnglishSlug = slug === data.product.translation?.slug;
const locale = isEnglishSlug ? 'en' : 'sr';
return {
props: {
product: data.product,
currentLocale: locale,
isEnglishSlug,
},
};
};
export default function ProductPage({ product, currentLocale, isEnglishSlug }: any) {
const router = useRouter();
// Use translation if viewing English slug
const displayData = isEnglishSlug && product.translation
? product.translation
: product;
// URLs for language switcher
const serbianUrl = `/products/${product.slug}`;
const englishUrl = product.translation?.slug
? `/products/${product.translation.slug}`
: serbianUrl;
return (
<>
<Head>
<title>{displayData.name}</title>
{/* Canonical URL - Serbian version */}
<link rel="canonical" href={`https://dev.manoonoils.com${serbianUrl}`} />
{/* Alternate languages */}
<link rel="alternate" hrefLang="sr" href={`https://dev.manoonoils.com${serbianUrl}`} />
<link rel="alternate" hrefLang="en" href={`https://dev.manoonoils.com${englishUrl}`} />
</Head>
<article>
<h1>{displayData.name}</h1>
<div dangerouslySetInnerHTML={{ __html: displayData.description }} />
{/* Language Switcher */}
<div className="language-switcher">
<a href={serbianUrl} className={currentLocale === 'sr' ? 'active' : ''}>
🇷🇸 Srpski
</a>
<a href={englishUrl} className={currentLocale === 'en' ? 'active' : ''}>
🇬🇧 English
</a>
</div>
</article>
</>
);
}
Alternative: Cookie-Based Language Detection
If you want automatic language detection without URL prefix:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Only handle /products/ routes
if (!pathname.startsWith('/products/')) {
return NextResponse.next();
}
// Get language preference from cookie or header
const locale = request.cookies.get('NEXT_LOCALE')?.value ||
request.headers.get('accept-language')?.split(',')[0]?.slice(0, 2) ||
'sr';
// Store locale in cookie for subsequent requests
const response = NextResponse.next();
response.cookies.set('NEXT_LOCALE', locale);
return response;
}
export const config = {
matcher: ['/products/:path*'],
};
Option 1: Language Prefix URLs
/sr/product/organsko-maslinovo-ulje-500ml/ ← Serbian
/en/product/organic-olive-oil-500ml/ ← English
Storefront fetches correct translation:
query GetProduct($slug: String!, $locale: LanguageCodeEnum!) {
product(slug: $slug, channel: "default-channel") {
name
description
translation(languageCode: $locale) {
name
description
}
}
}
Database Schema
Core Product Tables
product_product ← Main product (Serbian default)
- id, name, slug, description
- product_type_id, category_id
- seo_title, seo_description
product_producttranslation ← English translation
- product_id, language_code
- name, slug, description
product_productvariant ← SKUs
- id, product_id, sku, name
product_productvariantchannellisting ← Pricing
- variant_id, channel_id
- price_amount, currency
warehouse_stock ← Inventory
- product_variant_id, quantity
Migration SQL Script
-- 1. Create temp table for WooCommerce export
CREATE TEMP TABLE temp_woocommerce_import (
wc_id INTEGER,
sku VARCHAR(255),
name_sr VARCHAR(250),
slug VARCHAR(255),
description_sr TEXT,
description_plain_sr TEXT,
price NUMERIC(20,3),
name_en VARCHAR(250),
description_en TEXT,
slug_en VARCHAR(255)
);
-- 2. Ensure default product type exists
INSERT INTO product_producttype (name, slug, has_variants, is_shipping_required, weight, is_digital, kind)
VALUES ('Default Type', 'default-type', false, true, 0, false, 'NORMAL')
ON CONFLICT (slug) DO NOTHING;
-- 3. Insert Serbian products (preserve WooCommerce slugs)
INSERT INTO product_product (
name, slug, description, description_plaintext,
product_type_id, seo_title, seo_description,
metadata, private_metadata, search_document, search_index_dirty,
weight, created_at, updated_at
)
SELECT
name_sr,
slug, -- PRESERVE WooCommerce slug!
jsonb_build_object('blocks', jsonb_build_array(
jsonb_build_object('type', 'paragraph', 'data',
jsonb_build_object('text', description_sr))
)),
COALESCE(description_plain_sr, LEFT(description_sr, 300)),
1, name_sr,
LEFT(COALESCE(description_plain_sr, description_sr), 300),
'{}', '{}', '', true,
0, NOW(), NOW()
FROM temp_woocommerce_import
ON CONFLICT (slug) DO NOTHING;
-- 4. Create variants (simple products = 1 variant each)
INSERT INTO product_productvariant (name, sku, product_id, track_inventory, weight, is_preorder, created_at, updated_at)
SELECT
'Default', t.sku, p.id, true, 0, false, NOW(), NOW()
FROM temp_woocommerce_import t
JOIN product_product p ON p.slug = t.slug
ON CONFLICT (sku) DO NOTHING;
-- 5. Update default_variant
UPDATE product_product p
SET default_variant_id = v.id
FROM product_productvariant v
WHERE v.product_id = p.id;
-- 6. Create channel listings (publish all products)
INSERT INTO product_productchannellisting (
published_at, is_published, channel_id, product_id,
currency, visible_in_listings, discounted_price_dirty
)
SELECT NOW(), true, 1, p.id, 'USD', true, false
FROM product_product p
WHERE p.id NOT IN (SELECT product_id FROM product_productchannellisting)
ON CONFLICT (product_id, channel_id) DO NOTHING;
-- 7. Add pricing from WooCommerce
INSERT INTO product_productvariantchannellisting (
currency, price_amount, channel_id, variant_id
)
SELECT 'USD', t.price, 1, v.id
FROM temp_woocommerce_import t
JOIN product_product p ON p.slug = t.slug
JOIN product_productvariant v ON v.product_id = p.id
ON CONFLICT (variant_id, channel_id) DO UPDATE SET price_amount = EXCLUDED.price_amount;
-- 8. Add English translations with DIFFERENT slugs
-- English slug is stored in translation table and will be used for /products/english-slug/ URLs
INSERT INTO product_producttranslation (
product_id, language_code, name, slug, description, seo_title, seo_description
)
SELECT
p.id,
'en',
t.name_en,
-- IMPORTANT: Use different English slug for /products/english-slug/ URL
COALESCE(NULLIF(t.slug_en, ''),
LOWER(REGEXP_REPLACE(t.name_en, '[^a-zA-Z0-9]+', '-', 'g'))),
jsonb_build_object('blocks', jsonb_build_array(
jsonb_build_object('type', 'paragraph', 'data',
jsonb_build_object('text', COALESCE(t.description_en, t.description_sr)))
)),
t.name_en,
LEFT(COALESCE(t.description_en, t.description_sr), 300)
FROM temp_woocommerce_import t
JOIN product_product p ON p.slug = t.slug
WHERE t.name_en IS NOT NULL AND t.name_en != ''
ON CONFLICT (language_code, product_id) DO UPDATE SET
name = EXCLUDED.name,
slug = EXCLUDED.slug,
description = EXCLUDED.description;
-- Verify both slugs exist
SELECT
p.slug as serbian_slug,
pt.slug as english_slug
FROM product_product p
LEFT JOIN product_producttranslation pt ON pt.product_id = p.id AND pt.language_code = 'en'
LIMIT 5;
-- 9. Trigger search reindex
UPDATE product_product SET search_index_dirty = true;
-- 10. Clean up
DROP TABLE temp_woocommerce_import;
GraphQL Query Example
query ProductDetail($slug: String!, $locale: LanguageCodeEnum!) {
product(slug: $slug, channel: "default-channel") {
id
name # Serbian name (default)
slug # Serbian slug
description # Serbian description
translation(languageCode: $locale) {
name # English name
slug # English slug
description # English description
}
variants {
id
name
sku
translation(languageCode: $locale) {
name
}
channelListings {
price {
amount
currency
}
}
}
}
}
Next.js Storefront Example
// pages/product/[slug].tsx
export const getStaticProps: GetStaticProps = async ({ params, locale }) => {
const { data } = await saleorClient.query({
query: GET_PRODUCT,
variables: {
slug: params?.slug,
locale: locale?.toUpperCase() || 'SR'
},
});
return {
props: {
product: data.product,
},
};
};
export default function ProductPage({ product }) {
const router = useRouter();
const displayName = product.translation?.name || product.name;
return (
<>
<Head>
<link rel="canonical" href={`https://dev.manoonoils.com/product/${product.slug}`} />
<link rel="alternate" hrefLang="sr" href={`/product/${product.slug}`} />
<link rel="alternate" hrefLang="en" href={`/en/product/${product.translation?.slug || product.slug}`} />
</Head>
<h1>{displayName}</h1>
</>
);
}
Summary
| Requirement | Solution |
|---|---|
URL structure /products/ |
Next.js pages directory: pages/products/[slug].tsx |
| Different slugs per language | Store English slug in product_producttranslation.slug |
| No language code in URL | Both /products/serbian-slug/ and /products/english-slug/ work independently |
| Language switching | User clicks link to go from Serbian URL to English URL |
| SEO preservation | Canonical URL = Serbian, hreflang tags for both versions |
URL Examples
| Language | Product Name | URL |
|---|---|---|
| Serbian | Organsko Maslinovo Ulje | /products/organsko-maslinovo-ulje-500ml/ |
| English | Organic Olive Oil | /products/organic-olive-oil-500ml/ |
Database Values
-- product_product (Serbian - default)
slug: 'organsko-maslinovo-ulje-500ml'
name: 'Organsko Maslinovo Ulje 500ml'
-- product_producttranslation (English)
language_code: 'en'
slug: 'organic-olive-oil-500ml' ← Different slug!
name: 'Organic Olive Oil 500ml'
See full documentation with code examples in this file.