David Lewin David Lewin
SEO, Traffic & Automation

Optimizing multilingual SEO on WooCommerce: Linguise vs manual translation

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

Multilingual SEO on a big WooCommerce store comes down to three jobs. First, every language version of a product needs its own crawlable URL with a complete, reciprocal hreflang set and a self-referencing canonical. Second, text that WooCommerce renders in the browser (block cart and checkout, Ajax fragments, React catalogs reading the Store API) must be translated too, or shoppers switch language halfway through checkout. Third, the word budget must fit the catalog. Linguise covers the first two server-side: it translates URLs, prints hreflang, and translates the Store API's cart and checkout JSON. Reading its 2.2.70 source shows that it skips the /wc/store/v1/products route unless you add it with two filters, which our tested mu-plugin does. Its Large plan has no word cap (fair use applies) for $45 a month, which is why it's our pick past 10,000 products; Weglot is the strong alternative, priced by unique words. Translate by hand where words sell: top products, categories and legal pages. Exclude SKUs, prices and account pages, and audit hreflang clusters with the script below.

Translating a product page is the easy part. The SEO work is everything around it. Search engines have to find each language at its own URL and understand which pages belong together. The cart and checkout have to stay in the shopper's language even though WooCommerce renders them in the browser. And on a catalog of 10,000 products or more, the word count has to fit a plan you can afford. This guide covers all three, with code you can run, and compares an automatic service (Linguise, with Weglot as the alternative) against translating by hand.

How this guide was made

We read the source code of Linguise 2.2.70 and Weglot 6.4.3 from wordpress.org. We tested the exclusion mu-plugin on a real WordPress 7.1.3 and WooCommerce 11.2.1 site, including Weglot's dynamic-content switch on real page views. We ran Linguise's own fragment collector, from its plugin code, over real Store API responses. The word-budget script ran against the site's REST API, and the hreflang audit against a test site with good and broken clusters. All 30 checks pass. We didn't connect a live Linguise or Weglot account, so translation quality and the vendors' servers aren't covered. Prices are from the vendors' pricing pages, checked in October 2026.

1. The short answer

LinguiseWeglotManual (WPML or Polylang + translators)
Translated URLs and hreflangYes, automaticYes, automaticYes, from translated posts
Block cart and checkout (Store API JSON)Translated server-sideJS library, off by defaultAlready translated in the database
Custom React or Ajax catalogNeeds two filters (below)Needs its JS library scoped to itDepends on your API language handling
Price at 10,000 products x 4 languages$45/month (Large, no word cap, fair use)From €699/month (Extended, 5M words)Licence plus translator fees per word
Quality controlLive editor; manual edits don't use quotaVisual editor and glossaryFull

2. Hreflang architecture for a product page

One product, three languages, one hreflang set
One product, three languages, one hreflang setEnglish page / French page / German page → The same alternate set in every head → Every alternate returns 200English page: /product/linen-shirt/, canonical to itselfEnglish page/product/linen-shirt/,canonical toitselfFrench page: /fr/produit/..., canonical to itselfFrench page/fr/produit/...,canonical toitselfGerman page: /de/produkt/..., canonical to itselfGerman page/de/produkt/...,canonical toitselfThe same alternate set in every head: en, fr, de, plus x-default for the originalThe same alternate set in every headen, fr, de, plus x-default for the originalEvery alternate returns 200: no redirects, no noindex, absolute URLsEvery alternate returns 200no redirects, no noindex, absolute URLs
Each language page lists all languages, itself included, and points its canonical at itself. One missing return link or a canonical to the English page and search engines can ignore the whole set.

In the HTML, every one of the three pages carries the same four alternates and its own canonical:

<!-- on /fr/produit/chemise-en-lin/ -->
<link rel="canonical" href="https://shop.example/fr/produit/chemise-en-lin/">
<link rel="alternate" hreflang="en" href="https://shop.example/product/linen-shirt/">
<link rel="alternate" hreflang="fr" href="https://shop.example/fr/produit/chemise-en-lin/">
<link rel="alternate" hreflang="de" href="https://shop.example/de/produkt/leinenhemd/">
<link rel="alternate" hreflang="x-default" href="https://shop.example/product/linen-shirt/">

The rules that break most often on stores:

  • Canonical to the original language. Some SEO plugins print one canonical for every language. A French page whose canonical is the English URL tells search engines "don't index me", which cancels its hreflang.
  • Variation and filter URLs. ?attribute_pa_colour=navy and layered-nav URLs shouldn't form their own clusters. Their canonical points at the parent product, and the translation plugins drop hreflang on non-canonical pages (Weglot's code does exactly that).
  • Region codes. Use fr unless you really run separate French and Canadian-French pages; then use fr-FR and fr-CA. en_GB with an underscore is invalid.
  • Untranslated products. If a product isn't translated into German, don't list a German alternate for it.

3. Translated URLs

A translated slug (/fr/produit/chemise-en-lin/ rather than /fr/product/linen-shirt/) helps click-through in the language and is a small relevance signal. Linguise translates URLs, stores the mapping in its own table in your database so incoming translated URLs resolve to the original page, and inserts the hreflang alternates (a setting that's on by default). Two cautions apply to any tool. Keep SKUs and model numbers out of slugs, because a translated model number is a broken search. And decide on slug translation before launch: changing slugs later means redirects for every product in every language.

4. Dynamic content: cart, checkout and JavaScript catalogs

A SaaS translator works on the HTML your server sends. Modern WooCommerce sends a lot of text another way: the cart and checkout blocks are React components that fetch JSON from the Store API (/wp-json/wc/store/v1/cart), classic themes refresh cart fragments over wc-ajax, and headless or custom catalogs read /wc/store/v1/products. If that text isn't translated, a French shopper sees French product pages and an English cart. Here's what the plugin source shows each tool doing:

ContentLinguise 2.2.70Weglot 6.4.3
Server-rendered pagesTranslatedTranslated
Classic cart fragments and wc-ajax (coupons, shipping, totals)Hooked and translatedThrough its output buffer, which reads the language of Ajax calls from the referrer; test coupons and shipping updates
Store API cart, batch, checkout, shipping-rate JSONTranslated server-side, allow-listed keys only (item names, descriptions, links)Not in the JSON; its JS library can translate the rendered block (off by default)
Store API /products (custom catalogs)Not translated until you add the route and allow keysJS library, if you add your catalog's selector

Details that matter in production:

  • Linguise picks the language from the Referer header of the API call. Browsers send the full URL for same-origin requests by default, but a Referrer-Policy: no-referrer (or origin) header from a security plugin silently turns the JSON translation off.
  • JSON translations aren't cached locally. Each Store API call on a translated page makes a request to Linguise's server; on any error the plugin returns the untranslated JSON, so checkout keeps working.
  • Weglot's JS library (weglot_translate_dynamics) is off by default, and even when switched on it only loads on pages you allow. Each dynamic string is then translated through a call from the visitor's browser, so keep it to the cart and checkout.
  • Database plugins (WPML, Polylang) don't translate text on the fly: the translated product already exists, so APIs only need to know which language to return. Check how your plugin passes the language to REST requests before you build a JavaScript catalog on it.

5. Exclusions as code: one mu-plugin for Linguise and Weglot

Some things must never be machine-translated: SKUs, prices, addresses, brand terms, and pages that only show one customer's data. Keeping them out saves words, prevents "translated" SKUs, and keeps customer data at home. Linguise's plugin sends the full HTML of each translated page view to its translation server, along with the visitor's IP address and user agent. In 2.2.70 we found no local URL exclusion, so URL rules are applied on Linguise's side after the page has been sent. Account pages are where that matters.

This mu-plugin keeps one list and applies it to both services. Drop it into wp-content/mu-plugins/; the vendor you don't run ignores its hooks.

<?php
/**
 * Plugin Name: Store i18n rules
 * Description: One list of what machine translation must not touch, applied to Weglot and Linguise.
 *
 * Drop into wp-content/mu-plugins/. Both vendors' hooks are registered; the one you don't run is ignored.
 */

defined( 'ABSPATH' ) || exit;

// Language prefixes your translation plugin serves (/fr/..., /de/...).
const STORE_I18N_LANGS = [ 'fr', 'de', 'es' ];

// Pages that only show one customer's own data. They have no search value, and translating them sends
// that customer's name, address and order lines to the translation vendor. Keep these slugs untranslated.
const STORE_I18N_PRIVATE = [ 'my-account', 'checkout/order-received' ];

// Rendered text that must read the same in every language.
const STORE_I18N_KEEP = [ '.sku', '.no-mt', '.woocommerce-product-attributes-item--dimensions td' ];

/*
 * 1. Private pages: /fr/my-account/... goes to /my-account/... before any translation plugin loads.
 * mu-plugins run before regular plugins, so this beats Linguise's early hook and Weglot's output buffer.
 */
$store_i18n_path = (string) parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH );
$store_i18n_re   = '#^/(' . implode( '|', STORE_I18N_LANGS ) . ')(/(?:' . implode( '|', array_map( 'preg_quote', STORE_I18N_PRIVATE ) ) . ')(?:/.*)?)$#';
if ( preg_match( $store_i18n_re, $store_i18n_path, $m ) ) {
	$query = (string) parse_url( $_SERVER['REQUEST_URI'], PHP_URL_QUERY );
	header( 'Cache-Control: no-store' );
	header( 'Location: ' . $m[2] . ( '' === $query ? '' : '?' . $query ), true, 302 );
	exit;
}

/*
 * 2. Weglot: selectors it must skip, and its JS engine only where WooCommerce renders text in the browser.
 * Weglot's own defaults already skip .amount and address.
 */
add_filter( 'weglot_exclude_blocks', fn( $blocks ) => array_merge( $blocks, STORE_I18N_KEEP ) );
add_filter( 'weglot_excluded_blocks_selectors', fn( $blocks ) => array_merge( $blocks, array_map( fn( $s ) => [ 'value' => $s ], STORE_I18N_KEEP ) ) );
add_filter( 'weglot_translate_dynamics', '__return_true' );
// Default is off everywhere; every dynamic string is an API call from the visitor's browser, so scope it.
add_filter( 'weglot_load_dynamics_script', fn( $load ) => $load || is_cart() || is_checkout() );

/*
 * 3. Linguise: it translates Store API JSON only for routes it knows (cart, batch, checkout) and, inside
 * them, only keys on its allow list. A React or Ajax catalog on /wc/store/v1/products needs both.
 */
add_filter( 'linguise_ajax_intercept_prefixes', fn( $prefixes ) => array_merge( $prefixes, [ 'wc/store/v1/products' ] ) );
add_filter( 'linguise_fragment_filters', function ( $filters ) {
	$product = '^(\d+\.)?';
	return array_merge(
		[
			[ 'key' => $product . 'sku$', 'mode' => 'regex_full', 'kind' => 'deny' ],
			[ 'key' => $product . '(name|short_description|description|permalink)$', 'mode' => 'regex_full', 'kind' => 'allow' ],
			[ 'key' => $product . '(categories|tags)\.\d+\.name$', 'mode' => 'regex_full', 'kind' => 'allow' ],
			[ 'key' => $product . 'attributes\.\d+\.(name|terms\.\d+\.name)$', 'mode' => 'regex_full', 'kind' => 'allow' ],
		],
		$filters
	);
} );

// Linguise's HTML selector exclusions live in its dashboard (Rules > CSS selector): paste
// implode( ', ', STORE_I18N_KEEP ) there. In theme markup, class="no-mt" translate="no" covers both vendors'
// lists and also stops browser translators.
  • Private pages. A request for /fr/my-account/... or /de/checkout/order-received/... is redirected (302, no-store) to the untranslated URL, query string and order key included, before any translation plugin loads. mu-plugins run before regular plugins, so this comes ahead of Linguise's earliest hook. Keep those slugs untranslated so the pattern matches.
  • Selectors. Weglot reads the list through weglot_exclude_blocks (and its JS engine through weglot_excluded_blocks_selectors). Its own defaults already exclude .amount (prices) and address. Linguise takes CSS-selector rules in its dashboard, so paste the same list there; in theme markup, translate="no" works for Linguise and for browser translators.
  • Weglot's JS engine loads only on the cart and checkout. We checked the filter on real page views: off on a product page, on in the cart.
  • Linguise and the products API. Without the filters, Linguise's collector takes nothing from /wc/store/v1/products responses. It runs in strict mode for API JSON, where only allow-listed keys qualify, and that route isn't on its list. With the filters it takes product names, descriptions, short descriptions, permalinks and category names, and leaves SKUs and prices alone. We tested both cases with Linguise's own code.

6. The translation budget at 10,000+ products

Word quotas count each language separately, and catalogs are bigger than people expect. Measure yours before choosing a plan. This script reads products through the WooCommerce REST API with a read-only key and reports two numbers. Total is every word as it appears. Unique counts identical sentences once, so shared care instructions and shipping notes are translated only once.

"""Estimate the translation word budget of a WooCommerce catalog before you buy a plan.

Reads products through the WooCommerce REST API (read-only key), counts the words in the fields a
translation service will see, and prints two numbers per catalog:
  total  - every word as it appears (what a "words on your pages" quota grows with)
  unique - words after de-duplicating identical text segments (repeated boilerplate is translated once)
Both are multiplied by the number of target languages, because quotas count each language separately.

  python word_budget.py https://shop.example --key ck_... --secret cs_... --languages 4
"""
import argparse, base64, html, json, re, sys, urllib.parse, urllib.request

FIELDS = ['name', 'short_description', 'description', 'categories', 'tags', 'attributes']
WORD = re.compile(r"\w+(?:['’-]\w+)*")
# Published plan limits as of October 2026; check the vendor pricing pages before you rely on them.
# Weglot counts unique words; Linguise counts "words of your pages x languages", so it is checked against
# the total, which is the safe reading until Linguise confirms it de-duplicates.
BASIS = {'Weglot': 'unique', 'Linguise': 'total'}
PLANS = {
    'Weglot': [('Starter', 10_000, 1), ('Business', 50_000, 3), ('Pro', 200_000, 5),
               ('Advanced', 1_000_000, 10), ('Extended', 5_000_000, 20)],
    'Linguise': [('Start', 200_000, None), ('Pro', 600_000, None), ('Large', None, None)],
}


def segments(product, fields):
    """Yield the translatable text segments of one product."""
    for f in fields:
        v = product.get(f)
        if f in ('categories', 'tags'):
            yield from (t['name'] for t in v or [])
        elif f == 'attributes':
            for a in v or []:
                yield a['name']
                yield from a.get('options', [])
        elif v:
            text = html.unescape(re.sub(r'<[^>]+>', '\n', v))
            yield from (s.strip() for s in re.split(r'\n+|(?<=[.!?])\s+', text) if s.strip())


def count(products, fields):
    total, seen, unique = 0, set(), 0
    for p in products:
        for s in segments(p, fields):
            n = len(WORD.findall(s))
            total += n
            key = ' '.join(s.lower().split())
            if key not in seen:
                seen.add(key)
                unique += n
    return total, unique


def fetch(store, key, secret, fields):
    auth = 'Basic ' + base64.b64encode(f'{key}:{secret}'.encode()).decode()
    page = 1
    while True:
        q = urllib.parse.urlencode({'per_page': 100, 'page': page, 'status': 'publish', '_fields': ','.join(fields)})
        req = urllib.request.Request(f'{store.rstrip("/")}/wp-json/wc/v3/products?{q}', headers={'Authorization': auth})
        with urllib.request.urlopen(req, timeout=60) as r:
            batch = json.load(r)
            pages = int(r.headers.get('X-WP-TotalPages', 1))
        yield from batch
        if page >= pages:
            return
        page += 1


def cheapest(words, languages):
    out = {}
    for vendor, plans in PLANS.items():
        need = words[BASIS[vendor]] * languages
        fit = next((name for name, limit, langs in plans
                    if (limit is None or need <= limit) and (langs is None or languages <= langs)), None)
        out[vendor] = fit or 'Enterprise / contact sales'
    return out


def main(argv=None):
    ap = argparse.ArgumentParser()
    ap.add_argument('store')
    ap.add_argument('--key', required=True)
    ap.add_argument('--secret', required=True)
    ap.add_argument('--languages', type=int, required=True, help='target languages, not counting the original')
    ap.add_argument('--fields', default=','.join(FIELDS))
    a = ap.parse_args(argv)
    fields = a.fields.split(',')
    products = list(fetch(a.store, a.key, a.secret, fields))
    total, unique = count(products, fields)
    report = {
        'products': len(products), 'languages': a.languages,
        'words_per_language': {'total': total, 'unique': unique},
        'words_all_languages': {'total': total * a.languages, 'unique': unique * a.languages},
        'smallest_plan': cheapest({'total': total, 'unique': unique}, a.languages),
        'note': 'Catalog fields only: add theme, menus, checkout and email strings on top.',
    }
    json.dump(report, sys.stdout, indent=2)
    print()
    return report


if __name__ == '__main__':
    main()

Run it with your target languages (not counting the original):

python word_budget.py https://shop.example --key ck_xxx --secret cs_xxx --languages 4

It checks Weglot against unique words (Weglot's limit is "total unique words translated across all your languages") and Linguise against total words, because Linguise describes its quota as the words of your pages multiplied by the number of languages. If Linguise confirms it de-duplicates, your real usage will be lower. The script counts catalog fields only. Theme text, menus, checkout labels and emails come on top, though they're small next to a catalog.

PlanWordsLanguagesPrice
Linguise Start200,000Unlimited$15/month
Linguise Pro600,000Unlimited$25/month
Linguise LargeNo cap (fair use: around 100,000 translated page hits a day)Unlimited$45/month
Weglot Pro200,0005€79/month
Weglot Advanced1,000,00010€299/month
Weglot Extended5,000,00020€699/month

At 10,000 products averaging 120 unique words, four languages are 4.8 million words. That's Weglot's Extended plan or Linguise's Large plan, which is the main reason Linguise is our pick for big catalogs. Three ways to cut any budget:

  • Translate in waves. Names, short descriptions and categories first, since they appear in listings and search results. Then long descriptions for the products that sell in that market.
  • Exclude what shouldn't change (see section 5). On Linguise, excluding content later doesn't give back quota already used, so set exclusions before the first crawl.
  • Edit by hand where it counts. On Linguise, manual edits don't count towards the quota.

7. Audit your hreflang clusters

Whichever tool prints the tags, check them on real URLs after launch and after every theme or SEO-plugin update. This script takes one URL per product, follows every alternate, and checks the whole cluster: status codes, self-reference, return links, canonicals, noindex, language codes, x-default and <html lang>.

"""Audit hreflang clusters on a multilingual store.

Give it one URL per product (any language). It fetches the page, follows every hreflang alternate and
checks the whole cluster:
  ERROR  alternate does not return 200 itself (redirects and 404s break the cluster)
  ERROR  page does not list itself
  ERROR  return links missing (A lists B but B does not list A, or lists a different URL)
  ERROR  canonical points somewhere else (a /fr/ page canonical to the English URL cancels its hreflang)
  ERROR  page is noindex
  ERROR  invalid or duplicated language code
  WARN   no x-default
  WARN   <html lang> disagrees with the hreflang the cluster gives that page

  python hreflang_audit.py https://shop.example/product/tee/ https://shop.example/fr/produit/tee/
Exit code 1 when any ERROR is found.
"""
import re, sys, urllib.error, urllib.parse, urllib.request
from html.parser import HTMLParser

CODE = re.compile(r'^(x-default|[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?)$', re.I)


class Head(HTMLParser):
    def __init__(self):
        super().__init__()
        self.alternates, self.canonical, self.noindex, self.lang = [], None, False, None

    def handle_starttag(self, tag, attrs):
        a = {k: v or '' for k, v in attrs}
        rel = a.get('rel', '').lower().split()
        if tag == 'html':
            self.lang = a.get('lang')
        elif tag == 'link' and 'alternate' in rel and 'hreflang' in a:
            self.alternates.append((a['hreflang'], a.get('href', '')))
        elif tag == 'link' and 'canonical' in rel:
            self.canonical = a.get('href')
        elif tag == 'meta' and a.get('name', '').lower() in ('robots', 'googlebot') and 'noindex' in a.get('content', '').lower():
            self.noindex = True


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *a, **k):
        return None


OPENER = urllib.request.build_opener(NoRedirect)


def norm(url):
    p = urllib.parse.urlsplit(url)
    return urllib.parse.urlunsplit((p.scheme.lower(), p.netloc.lower(), p.path or '/', p.query, ''))


def fetch(url):
    req = urllib.request.Request(url, headers={'User-Agent': 'hreflang-audit/1.0'})
    try:
        with OPENER.open(req, timeout=30) as r:
            status, body, robots = r.status, r.read().decode('utf-8', 'replace'), r.headers.get('X-Robots-Tag', '')
    except urllib.error.HTTPError as e:
        return e.code, None
    h = Head()
    h.feed(body)
    h.noindex = h.noindex or 'noindex' in robots.lower()
    return status, h


def audit(start):
    issues, pages, todo = [], {}, [norm(start)]
    while todo:
        url = todo.pop()
        if url in pages:
            continue
        status, head = fetch(url)
        pages[url] = head
        if status != 200:
            issues.append(('ERROR', url, f'returns HTTP {status}, alternates must return 200 themselves'))
            continue
        todo += [norm(urllib.parse.urljoin(url, href)) for _, href in head.alternates]

    for url, head in pages.items():
        if head is None:
            continue
        mine = {}
        for code, href in head.alternates:
            target = norm(urllib.parse.urljoin(url, href))
            if not CODE.match(code):
                issues.append(('ERROR', url, f'invalid hreflang code "{code}"'))
            elif code.lower() in mine and mine[code.lower()] != target:
                issues.append(('ERROR', url, f'hreflang "{code}" points to two URLs'))
            mine[code.lower()] = target
        if url not in mine.values():
            issues.append(('ERROR', url, 'does not list itself in its hreflang set'))
        if 'x-default' not in mine:
            issues.append(('WARN', url, 'no x-default'))
        if head.canonical and norm(urllib.parse.urljoin(url, head.canonical)) != url:
            issues.append(('ERROR', url, f'canonical points to {head.canonical}, which cancels hreflang'))
        if head.noindex:
            issues.append(('ERROR', url, 'noindex page inside an hreflang cluster'))
        own = [c for c, t in mine.items() if t == url and c != 'x-default']
        if head.lang and own and not any(head.lang.lower().startswith(c.split('-')[0]) for c in own):
            issues.append(('WARN', url, f'<html lang="{head.lang}"> but hreflang says {own}'))
        for code, target in mine.items():
            other = pages.get(target)
            if other is None or target == url:
                continue
            back = {norm(urllib.parse.urljoin(target, h)) for _, h in other.alternates}
            if url not in back:
                issues.append(('ERROR', url, f'{target} ({code}) does not link back to this URL'))
    return pages, issues


def main(argv=None):
    found = []
    for start in (argv if argv is not None else sys.argv[1:]):
        pages, issues = audit(start)
        print(f'{start}: {len(pages)} pages in cluster, {len(issues)} issues')
        for level, url, msg in issues:
            print(f'  {level:5} {url}  {msg}')
        found += issues
    return 1 if any(level == 'ERROR' for level, _, _ in found) else 0


if __name__ == '__main__':
    sys.exit(main())
python hreflang_audit.py https://shop.example/product/linen-shirt/ https://shop.example/product/canvas-tote/
https://shop.example/product/linen-shirt/: 3 pages in cluster, 0 issues
https://shop.example/product/canvas-tote/: 4 pages in cluster, 2 issues
  ERROR https://shop.example/de/produkt/canvas-tasche/  canonical points to https://shop.example/product/canvas-tote/, which cancels hreflang
  WARN  https://shop.example/de/produkt/canvas-tasche/  <html lang="en"> but hreflang says ['de']

The output above is illustrative. In our tests the script caught each fault we planted: a missing return link, a canonical to another language, a redirecting alternate, a noindex alternate, an invalid code, a missing x-default and a mismatched lang attribute. It exits with code 1 on any error, so you can run it in CI against a sample of top products.

8. Where manual translation still wins

Machine translation is good enough for most catalog text. Human work still pays off on a small set of pages:

  • Your top products and categories in each market. These pages rank and convert, so local search terms matter. A translator who knows what French shoppers type ("chemise en lin" vs "chemise lin homme") beats literal accuracy.
  • Legal and checkout text. Returns, warranty, terms and payment wording carry obligations.
  • Brand voice. Taglines and campaign copy.

The practical setup is hybrid: let the service translate the whole catalog, then have a translator correct the top pages in its editor. With Linguise those edits don't use quota. A fully manual setup (WPML or Polylang with translators) gives the most control, at a per-word cost that a 10,000-product catalog makes hard to justify outside the top sellers.

9. Our opinion: trade-offs

  • For large catalogs, Linguise's Large plan changes the maths: no word cap for $45 a month, against Weglot's highest published tier. Read the fair-use terms first if your translated traffic is very high.
  • Weglot is the more polished alternative, with a big integration ecosystem and a clean failure mode (original HTML, never cached). Pick it when the unique word count fits a plan comfortably.
  • Test the dynamic parts, not just product pages. Add to cart in each language and go through checkout. That's where untranslated text and broken language detection show up.
  • Treat hreflang as code. Audit it automatically, because SEO plugins, themes and caches all change it without telling you.

Related: Weglot vs WPML at scale: database growth and caching and caching WooCommerce without leaking carts.

10. Tools

Top picks

ToolRoleWhy it fits
LinguiseAI website translationOur pick for catalogs past 10,000 products. It translates URLs and prints hreflang, translates WooCommerce's cart and checkout JSON server-side, and its Large plan has no word cap. Unlimited languages on every plan.
WeglotSaaS website translationThe excellent alternative: subdirectory URLs, automatic hreflang, WooCommerce prices and addresses excluded by default, and a JS engine for client-rendered blocks. Priced by unique words.

Alternatives: free tools and references

We don't earn anything from these links.

11. Pitfalls

  • Testing only product pages. The cart, mini-cart, checkout and order emails are where language breaks.
  • A strict Referrer-Policy that stops Linguise from knowing the language of API calls.
  • Translating account pages, which sends customer data to a third party and adds nothing to search.
  • Setting exclusions after the first crawl, when the words are already spent.
  • Region codes without region pages. fr-FR and fr-CA pointing at the same URL are just noise.
  • Letting hreflang drift. Run the audit after every plugin update.
Bottom line

Give every language its own URL with a complete, reciprocal hreflang set and a self-referencing canonical, and audit it automatically. Make sure the cart, checkout and any JavaScript catalog are translated too: Linguise does the Store API server-side, but custom routes need two filters. Keep SKUs, prices and account pages out with one exclusion list. Measure your word count before you buy. Past 10,000 products, Linguise's uncapped Large plan is the economical pick, Weglot is the excellent alternative when the word count fits, and human translators belong on the pages that sell most.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.