>
);
}
```
### Alternative: Cookie-Based Language Detection
If you want automatic language detection without URL prefix:
```typescript
// 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:**
```graphql
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
```sql
-- 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
```graphql
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
```typescript
// 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 (
<>
{displayName}
>
);
}
```
## 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
```sql
-- 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.