Skip to content

✨ Move locale into a [locale] route segment so tenant pages render static/ISR#737

Open
joshbermanssw wants to merge 8 commits into
mainfrom
locale-route-segment-735
Open

✨ Move locale into a [locale] route segment so tenant pages render static/ISR#737
joshbermanssw wants to merge 8 commits into
mainfrom
locale-route-segment-735

Conversation

@joshbermanssw

@joshbermanssw joshbermanssw commented Jun 1, 2026

Copy link
Copy Markdown
Member

TL;DR

The whole tenant site renders dynamically on every request — the shared layout (app/[product]/layout.tsx) calls getLocale()headers(), which opts every route into dynamic rendering, so each crawler/bot hit runs a server-side Tina/GraphQL query (~0.6s). This moves locale into a [locale] route segment sourced from params instead of a header, so the homepage and content pages become static/ISR (CDN-cacheable). Public URLs are unchanged.

Why

Structural follow-up to #735 (the quick-win cache PR is #736). Function Duration is dominated by re-rendering for automated traffic. A page can only be CDN-cached per-locale if locale comes from the route, not a request header — hence the [locale] segment.

Build output confirms the flip from dynamic → SSG/ISR:

Route Before After
/[locale]/[product] (homepage) ƒ dynamic SSG, revalidate 1h
/[locale]/[product]/blog, blog/[slug], docs, docs/[slug] ƒ dynamic SSG/ISR
/[locale]/[product]/[filename] ƒ (via dynamic layout) SSG, revalidate 1h

pnpm build succeeds; 103 static pages generated, including both /en/YakShaver/blog and /zh/YakShaver/blog (zh dimension works).

How

  1. Route tree moved app/[product]/**app/[locale]/[product]/** (git renames, history preserved).
  2. Middleware rewrites the locale into the internal path via a new pure helper resolveRequestRouteyakshaver.cn/x/zh/YakShaver/x, ssw.com.au/x/en/SSW/x, local /zh/x/zh/<default>/x. Rewrite, not redirect — public URLs are byte-identical.
  3. Pages/layout read locale from params; getLocale() and the x-language header are deleted.
  4. generateStaticParams gains a locale dimension via localeFromBreadcrumbs (en for all tenants; zh additionally for YakShaver, the only tenant with zh content).
  5. [filename] ISR uncapped to 1h — raised revalidate: 103600 on the inner Tina fetch in both getPageData and the page's generateMetadata (Next uses the lowest revalidate across a route, so the revalidate = 3600 export was previously capped to 10s). Build-confirmed at 1h.
graph LR
    R[Request host+path] --> M{middleware}
    M -->|resolveRequestRoute| P["/locale/product/..."]
    P --> T["app/[locale]/[product] (SSG/ISR)"]
Loading

Files to review (start here):

File Why
utils/resolveRequestRoute.ts (start here) Pure locale/product/path resolver — the routing contract. Unit-tested (9 cases).
middleware.ts Now a thin orchestrator over the resolver; sets up the rewrite.
utils/localeFromBreadcrumbs.ts How generateStaticParams decides en vs zh from Tina breadcrumbs.
app/[locale]/[product]/layout.tsx The change that removes the forced-dynamic headers() read.

Reviewer notes

  • Public URLs unchanged. Locale routing is internal rewrites; verify on the preview that yakshaver.ai serves en and a .cn host serves zh, and the language toggle round-trips.
  • Domain-based locale preserved. Prod locale is by domain (no /zh prefix); the /zh prefix is local/staging only — matches existing LanguageToggle/environment.ts behavior.
  • page-data API callers updated. The data-helper signatures gained a locale arg; the cookie-based preview routes now pass ("en", branch) positionally (they're English-only fallback routes).
  • privacy/feedback/sitemap.xml stay dynamic (no generateStaticParams) — low-traffic; can be made static in a follow-up.

Tests

utils/resolveRequestRoute.ts (9 cases) and utils/localeFromBreadcrumbs.ts (4 cases) are unit-tested. pnpm test green (18). pnpm exec tsc --noEmit clean. pnpm build succeeds with the route table above.

Note: local next build logs a non-fatal TypeError: c.join is not a function from the Tina client during generateStaticParams (also present on main — pre-existing/environmental); the build still completes and generates all 103 pages. Flagging for a separate look.

Links

@vercel

vercel Bot commented Jun 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ssw-products Ready Ready Preview, Comment Jun 1, 2026 6:44am

Request Review

@suiyangqiu suiyangqiu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of code-craft notes from a review pass, both non-blocking. Left inline on the relevant lines.

}
}

export async function generateStaticParams() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateStaticParams is copy-pasted across three siblings. This block, plus the equivalents in docs/[slug]/page.tsx and [filename]/page.tsx, are structurally identical: query a *Connection, then map edges to { locale: localeFromBreadcrumbs(bc), product: bc[0], <leaf>: filename }. Only the client query and the leaf key name differ. The page.tsx/blog/page.tsx/docs/page.tsx index pages also repeat the same seen/params dedup loop verbatim.

Worth pulling into one helper in utils/, e.g. staticParamsFromConnection(edges, leafKey), so each page becomes a one-liner and there is a single place to fix a future copy-paste bug. Non-blocking.


const data = await getPageData(product, relativePath, branch);
// English-only preview/fallback route; branch is the 4th positional arg.
const data = await getPageData(product, relativePath, "en", branch);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent page-data call signatures. getPageData(product, relativePath, "en", branch) and getBlogPageData are positional with a defaulted 3rd locale arg, while getDocPageData({ product, slug, locale }) and the get*WithFallback helpers take an options object. The ("en", branch) positional call here is the fragile seam - easy to pass an arg in the wrong slot.

Since these signatures are already being touched in this PR, worth standardising on the object form (getPageData({ product, filename, locale, branch })) across the page-data helpers. Non-blocking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants