Files
manoon-headless/saleor-migration.md
Unchained 7b94537670 feat(saleor): Phase 1 - GraphQL Client Setup
- 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
2026-03-21 12:36:21 +02:00

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>
    </>
  );
}

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.