Give every language its own URL, then annotate those URLs with reciprocal hreflang tags. A dropdown that swaps strings in React state translates the interface for the person already on your site, but it does not create anything for a search engine to index, so your German and Spanish versions stay invisible. globalize.now is AI-powered localization infrastructure that extracts hardcoded strings into committed Lingui PO catalogs and keeps them in sync on every Git push, which is what makes per-locale routes practical to maintain instead of a one-off chore.

This is the step most Lovable builders skip. The app gets translated, the switcher works in the browser, and traffic never arrives.


Why doesn't Google index my Lovable app's other languages?

Because those languages almost certainly do not have addresses.

The default pattern an AI builder produces is a useState locale value and a dropdown that sets it. Every language renders at the same URL. A crawler requesting that URL gets one response, in one language, and indexes one page. There is nothing for it to discover, and no signal telling it that other language versions exist.

The fix is structural, not cosmetic. Google's own guidance on localized versions is explicit that alternate language pages are identified by URL, and that the annotations connecting them have to be returned by each page in the set. If /de/pricing does not exist as a fetchable address, no amount of markup will surface it.


What does a real language switcher need to do?

Three things, in order: change the URL, keep the user on the same page, and remember the choice.

Changing the URL is the part that matters for search. Keeping the user on the same page is the part that matters for the user, and it is where most implementations fail: switching from /en/pricing should land on /de/pricing, not on /de/. Remembering the choice is a convenience, and it should never override an explicit locale in the URL.

One anti-pattern worth naming: automatic redirects based on browser language. If a crawler in the United States requests /de/pricing and gets bounced to /en/pricing, the German page effectively does not exist. Offer the switch, do not force it.


How do I add locale routing to a Lovable TanStack Start app?

Add a locale segment at the top of the route tree and activate the catalog from it.

Lovable moved new projects to server-side rendering on TanStack Start in May 2026, so a locale-prefixed route returns fully translated HTML on the first response. That is exactly what you want a crawler to receive.

Create a dynamic segment that wraps the rest of your routes:

src/routes/
  $locale/
    route.tsx
    index.tsx
    pricing.tsx

Then activate the Lingui catalog for that locale before the children render:

// src/routes/$locale/route.tsx
import { createFileRoute, Outlet, notFound } from "@tanstack/react-router";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";

const LOCALES = ["en", "de", "es", "fr"] as const;

export const Route = createFileRoute("/$locale")({
  loader: async ({ params }) => {
    if (!LOCALES.includes(params.locale as (typeof LOCALES)[number])) {
      throw notFound();
    }
    const { messages } = await import(
      `../../locales/${params.locale}/messages.po`
    );
    i18n.loadAndActivate({ locale: params.locale, messages });
    return { locale: params.locale };
  },
  component: LocaleLayout,
});

function LocaleLayout() {
  return (
    <I18nProvider i18n={i18n}>
      <Outlet />
    </I18nProvider>
  );
}

Two details do real work here. Rejecting unknown locales with notFound() stops /xx/pricing from returning a soft 200 for every junk path a crawler tries. Loading the catalog in the route loader means the activation happens on the server, before the HTML is serialized.

If you are on the older stack, the same URL shape applies but the render path differs. The Lovable Vite i18n walkthrough covers that case, and the TanStack Start setup guide covers the SSR side in more depth.


How do I build the language switcher component?

Navigate to the same route with a different locale parameter, and render every option as a real link.

// src/components/LanguageSwitcher.tsx
import { Link, useLocation, useParams } from "@tanstack/react-router";

const LOCALES = {
  en: "English",
  de: "Deutsch",
  es: "Español",
  fr: "Français",
} as const;

export function LanguageSwitcher() {
  const { locale } = useParams({ from: "/$locale" });
  const { pathname } = useLocation();
  const rest = pathname.replace(`/${locale}`, "") || "/";

  return (
    <nav aria-label="Language">
      <ul>
        {Object.entries(LOCALES).map(([code, label]) => (
          <li key={code}>
            <Link
              to={`/${code}${rest}`}
              hrefLang={code}
              aria-current={code === locale ? "true" : undefined}
            >
              {label}
            </Link>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Render the options as anchors, not as buttons wired to a router call. Anchors are crawlable, which gives search engines a second discovery path to your locale routes on top of the sitemap. Label each option in its own language rather than with a flag: flags represent countries, and Spanish is not a country.


How do I emit hreflang tags correctly?

Emit one annotation per locale on every page in the set, including a self-reference, plus an x-default.

Google verifies that alternate URLs link back, and ignores annotations that are not reciprocal or that point away from a page's own canonical. That makes generating them programmatically the only sane approach, because a set of four languages across twenty pages is 320 annotations to keep consistent by hand.

In TanStack Start, build them in the route's head:

// src/routes/$locale/route.tsx (excerpt)
const SITE = "https://example.com";
const LOCALES = ["en", "de", "es", "fr"] as const;

export const Route = createFileRoute("/$locale")({
  head: ({ params, location }) => {
    const rest = location.pathname.replace(`/${params.locale}`, "") || "/";
    return {
      links: [
        { rel: "canonical", href: `${SITE}/${params.locale}${rest}` },
        ...LOCALES.map((code) => ({
          rel: "alternate",
          hrefLang: code,
          href: `${SITE}/${code}${rest}`,
        })),
        { rel: "alternate", hrefLang: "x-default", href: `${SITE}/en${rest}` },
      ],
    };
  },
});

Because the annotations are derived from the same LOCALES array the router uses, adding a language updates the routes, the switcher, and the hreflang set at once. That is the property to protect: one list, three consumers.

Add the same URLs to your sitemap with xhtml:link alternates, and make sure your <html lang> attribute matches the active locale. The lang attribute does not influence hreflang, but it does affect screen readers and browser translation prompts.


Why can't a runtime translation widget do this?

Because the widget translates after the page has already been served in English.

A runtime widget swaps text in the browser once its script loads. The first response a crawler receives is the English DOM, and the translated version exists only after client-side JavaScript has run. Some widgets do offer subdomain or subdirectory modes with generated hreflang, but the URL structure and the annotations then belong to the vendor's infrastructure, not to your repository.

That dependency has a cost that stopped being hypothetical. Lovalingo, a translation widget popular with Lovable builders, is closing on August 31, 2026, and its own guidance tells users to move to project-owned internationalization. When a widget goes away, the language URLs it generated go with it, and the site reverts to English. Committed catalogs and routes defined in your own code have no such switch. We wrote up the practical version of that migration in the Lovalingo alternatives comparison.


What about the classic Vite SPA stack?

The URL structure is identical; the rendering is not.

Lovable projects created before the TanStack Start switch are Vite plus React single-page apps, served as static files and rendered in the browser. You can still route on /:locale, still emit hreflang, and still keep catalogs in the repo. What you lose is the guarantee that a crawler's first byte contains translated content.

If SEO is the reason you are localizing at all, add prerendering for the locale routes so each one ships real HTML, or plan a move to the server-rendered stack. Do not solve it by adding a runtime widget on top of a client-rendered app; that stacks two client-side render passes and makes the first meaningful paint worse in every language.


How do you keep four locales from drifting?

Automate the extraction, and treat the catalogs as source code.

The reason per-locale routing gets abandoned is not the routing. It is that every new feature adds English strings that nobody extracts, so /de/ slowly fills with English fragments and starts looking worse than having no German at all. globalize.now handles that layer: add the lovable-i18n skill in your Lovable workspace and prompt Lovable to set up i18n, or install the skills locally:

npx skills add globalize-now/globalize-skills

Add --all to install for every agent. New strings get extracted, keys generated, catalogs translated, and the updated PO files committed back on every Git push.

Pricing is €20 per month per workspace, including €20 of translation credit (about 200,000 words) per month, then token-based usage at the same per-character rate. There are no per-seat and no per-language charges, so adding a fifth locale changes your routing config, not your bill. Signup comes with €5 of translation credit and does not need a card. More detail for developers and for people shipping AI-built apps.

Routing and hreflang are a one-afternoon job. Keeping four catalogs complete as the app changes is the part that never ends, and that is the part globalize.now automates.

globalize.now turns hardcoded app copy into translation-ready locale files and keeps them updated as you ship.

Try globalize.now free