i18n & localized URLs

website/i18n.md

studio.chat runs in English (default) and Spanish. URLs are translated per locale with no /es/ prefix — Spanish visitors see native Spanish slugs, English visitors see English ones, and every URL is its own indexable page.

Internal routeEN URLES URL
/// (shared — see "Homepage SEO" below)
/contact/contact/contacto
/faqs/faqs/preguntas
/privacy/privacy/privacidad
/projects/projects/proyectos
/projects/[slug]/projects/[slug]/proyectos/[slug]
/services/services/servicios
/services/rentals/services/rentals/servicios/alquiler
/services/rentals/catalog/services/rentals/catalog/servicios/alquiler/catalogo
/services/rentals/catalog/[sku]/services/rentals/catalog/[sku]/servicios/alquiler/catalogo/[sku]
/services/podcasts/services/podcasts/servicios/podcasts
/services/stills/services/stills/servicios/fotografia
/team/team/equipo

Configured in src/i18n/routing.ts with localePrefix: "never" and a pathnames map. ASCII-only ES slugs (no accents) — natural Spanish without copy-paste / punycode issues.

How visitors land on the right language

  1. First visit: next-intl middleware reads Accept-Language and serves the matching locale. The choice persists in a NEXT_LOCALE cookie.
  2. Subsequent visits: cookie wins.
  3. Explicit URL: navigating to /contacto always serves ES, regardless of cookie; /contact always serves EN. The URL beats the preference.
  4. Language switcher: LocaleSwitch uses next-intl's Link with locale=... + { pathname, params }, so dynamic routes resolve correctly (/projects/my-slug/proyectos/my-slug). It also sets scroll={false} to preserve viewport position across the swap.

Adding a new route

  1. Create the page under src/app/[locale]/<new-path>/page.tsx.

  2. Add the route to routing.ts pathnames:

    "/new-thing": { en: "/new-thing", es: "/nueva-cosa" },
    
  3. Add <new-thing> to STATIC_ROUTES in src/app/sitemap.ts so the sitemap emits both locale URLs with hreflang alternates.

  4. Update src/components/Footer.tsx nav (and Header.tsx if it needs a top-nav entry).

  5. Add translations under a new namespace in locales/en.json and locales/es.json.

For dynamic routes (/things/[id]), use the typed { pathname, params } object form when creating Link hrefs:

<Link href={{ pathname: "/things/[id]", params: { id: "abc" } }}>

Eyebrow / label convention

Translation values in locales/*.json carry natural text ("say hello", "subscribe for updates") — never the typewriter underscore aesthetic. The "say_hello_" rendering is applied at the render layer via toEyebrow() from @/lib/eyebrow:

import { toEyebrow } from "@/lib/eyebrow";

<p className="label">{toEyebrow(t("footer.sayHello"))}</p>
// "say hello" → "say_hello_"

Components that own an eyebrow prop (PageHero, SectionHeader, CtaSection) wrap with toEyebrow internally, so callers pass the raw translation value — no wrapping at the call site.

Homepage SEO (dynamic serving)

The homepage / is the only route without a distinct URL per locale. studio.chat/ returns either EN or ES content based on Accept-Language + the locale cookie. This is Google's "dynamic serving" pattern.

To make crawlers understand both versions exist:

  • [locale]/page.tsx emits hreflang alternates for en, es, and x-default — all pointing at /.
  • proxy.ts sets Vary: Accept-Language on responses to / so shared caches and search engines key on the request locale.

Fallback if SEO suffers

Dynamic serving is officially supported but less reliable than distinct URLs. If the Spanish homepage starts ranking poorly for Spanish queries despite the setup above:

Option A — give the ES homepage a distinct path (e.g. /inicio):

// routing.ts
"/": { en: "/", es: "/inicio" },

Sitemap already emits both via getPathname; the change is otherwise self-contained.

This is the textbook fix; we deferred it because the shared / URL is cleaner and the SEO impact is wait-and-see.

Things to know

  • next-intl pathnames are typed strictly. When you add a new route, Link and getPathname infer the new pathname keys at compile time. Dynamic routes require the { pathname, params } object form (see above).
  • Sitemap: src/app/sitemap.ts emits one entry per (route, locale) with alternates.languages mapping all locale variants. Search engines see the cross-link.
  • Skipped from pathnames: /launch and /design — English-only by design (launch gate is a temporary marketing surface; design is an internal style guide).
  • The office portal (/office/*) is locale-free and bypasses the next-intl system entirely (its own root layout, no [locale] segment). See proxy.ts short-circuit.