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 route | EN URL | ES 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
- First visit: next-intl middleware reads
Accept-Languageand serves the matching locale. The choice persists in aNEXT_LOCALEcookie. - Subsequent visits: cookie wins.
- Explicit URL: navigating to
/contactoalways serves ES, regardless of cookie;/contactalways serves EN. The URL beats the preference. - Language switcher:
LocaleSwitchuses next-intl'sLinkwithlocale=...+{ pathname, params }, so dynamic routes resolve correctly (/projects/my-slug→/proyectos/my-slug). It also setsscroll={false}to preserve viewport position across the swap.
Adding a new route
-
Create the page under
src/app/[locale]/<new-path>/page.tsx. -
Add the route to
routing.tspathnames:"/new-thing": { en: "/new-thing", es: "/nueva-cosa" }, -
Add
<new-thing>toSTATIC_ROUTESinsrc/app/sitemap.tsso the sitemap emits both locale URLs withhreflangalternates. -
Update
src/components/Footer.tsxnav (andHeader.tsxif it needs a top-nav entry). -
Add translations under a new namespace in
locales/en.jsonandlocales/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.tsxemits hreflang alternates foren,es, andx-default— all pointing at/.proxy.tssetsVary: Accept-Languageon 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,
LinkandgetPathnameinfer the new pathname keys at compile time. Dynamic routes require the{ pathname, params }object form (see above). - Sitemap:
src/app/sitemap.tsemits one entry per (route, locale) withalternates.languagesmapping all locale variants. Search engines see the cross-link. - Skipped from
pathnames:/launchand/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). Seeproxy.tsshort-circuit.