Blog
Why we built Ofrai on Next.js App Router
Par Ofrai Editorial (Editorial Team)
Ofrai ships on Next.js 16 App Router with six languages, a partitioned sitemap per locale, and JSON-LD on every long-form page. This is the story of how we landed there, what the framework gave us for free, and what we had to build around it to make multi-tenant SEO behave the way Google expects.
Why App Router
Three reasons we did not regret it. First, streaming: the model page renders the header and the breadcrumb instantly while the catalog loader runs server-side. We dropped our Time-to-First-Byte on /models from 600 ms to under 200 ms on the median request. Second, nested layouts: the marketing chrome lives in app/[locale]/layout.tsx, the studio chrome lives in app/[locale]/[studio]/layout.tsx, and the model page lives in app/[locale]/models/[slug]/page.tsx. None of them re-renders when navigation happens between siblings — the studio layout caches while the page slot fills. Third, server components by default: the only client code on most long-form pages is the language switcher, the share button, and the cookie banner. Hydration cost is dominated by next-intl, not by us. next-intl integrates with App Router through a server-side getRequestConfig and a client-side NextIntlClientProvider, which is the part of the migration that took a week and not a month.
i18n without subdomains
We do not run ofrai.com, ofrai.cn, ofrai.de. Every locale lives on the same origin under a path prefix — /en, /zh, /ja, /ko, /de, /fr. Three reasons: one TLS cert, one cookie scope, one set of CDN rules. The cost is that the URL carries the locale; the win is that we do not have to coordinate HSTS, CSP, and robots across six domains. next-intl reads the locale from the URL prefix and calls setRequestLocale so every getTranslations call inside the page resolves against the right messages/<locale>.json. We emit hreflang cluster tags in app/[locale]/layout.tsx — one <link rel='alternate' hreflang='x' href='<site>/<x>/<path>' /> per locale, plus an x-default pointing at /en. Sitemaps are split: sitemap.xml is an index, and each partition (marketing, models, compare, use-cases, tools, features, guides, scenes) ships its own <sitemap> file with one entry per locale per path. Eight partitions times six locales is the upper bound on what we publish today.
SEO wins that came for free
A lot, honestly. JSON-LD — the SeoArticle component emits Article, BreadcrumbList, and FAQPage JSON-LD on every long-form page, plus a Person block for the byline. That is one component, not a per-page decision. Canonical tags — app/[locale]/blog/[slug]/page.tsx calls buildPageMetadata with the absolute path and the framework handles the rest; we never write a canonical <link> by hand. ImageResponse OG images — app/api/og/route.tsx uses Next's ImageResponse to render dynamic 1200x630 OG cards with no third-party service in the loop. Sitemap partitioning — eight sitemap files, one index, and crawl budget is not a fight we have to have. Static rendering with revalidate — most long-form pages export revalidate = 3600, so Next pre-renders at build time and refreshes hourly; Googlebot sees a fresh timestamp on every crawl without us paying for it on the request path. The biggest single win is that none of this requires a CMS. The long-form copy lives in content/seo/*.ts as TypeScript modules, the App Router renders it, the sitemap reads it. No database, no admin UI, no preview server.
What we had to build around
The framework does not solve caching the way we wanted. Next defaults to force-cache for static routes and no-store for dynamic; we wanted something in the middle, namely stale-while-revalidate per route with a manual purge channel. We wrote a small helper that wraps the route handler, checks Redis for a tombstone key, and falls through to the static cache otherwise. ISR's revalidate export is per-route and global; our helper is per-route and per-key. We use both. CDN cache headers were the next surprise — Vercel's edge cache treats revalidate = 3600 as a hard ceiling on freshness, but we wanted to ship a content edit and have it visible inside fifteen minutes, so we added a manual revalidatePath() call in the editor pipeline that purges both the Next cache and the CDN edge. The deployment hook runs on every merge to main; it is not user-triggered. The third thing was middleware — App Router middleware runs on the Edge runtime, which means no Node APIs, so we kept middleware to the language-detection and redirect logic and pushed everything else into the route handler, which saved us from having to maintain a separate Edge-compatible auth module.
What we would change
Three things, in order of regret. RSC boundaries — we shipped some pages where a tiny client island pulled a whole server subtree into the client bundle; the rule we now follow is that anything that needs useState or useEffect is its own client component imported as a leaf, because the framework will not stop you from making the wrong choice. Hydration size — next-intl's client provider ships every message namespace for the current locale, and we now lazily import the namespace we actually use inside the page; the savings on /pricing were about 90 kB of JS. Middleware limits — Edge runtime is fast but cannot use the Stripe SDK or Drizzle, so if we had to redo it we would keep middleware to redirect logic only and never let it touch auth. The honest summary is that App Router got us 80 percent of what we wanted for free. The remaining 20 percent is the same integration tax every framework has — caching, CDN, and the parts of your stack that run before the framework.
FAQ
- Is Next.js good for SEO?
- Yes. App Router gives you streaming SSR, automatic canonical tags via generateMetadata, server components that keep client JS small, and an ImageResponse helper for dynamic OG cards. On Ofrai we lean on all four for the blog, compare, and model pages — none of it requires a third-party SEO service.
- Next.js App Router vs Pages Router — which is better for SEO?
- App Router is the better default for SEO today. Streaming lets the header and breadcrumb paint before the catalog loader resolves, server components keep hydration cost low, and the framework owns canonical and metadata so per-page mistakes are harder to make. Pages Router still works but you give up streaming and you write more boilerplate per page.
- How do you do hreflang in Next.js?
- In App Router, render one <link rel='alternate' hreflang='<locale>' href='<site>/<locale>/<path>' /> per locale plus an x-default pointing at the default locale, inside the root layout or via a route-segment generateMetadata. We also emit one <sitemap> file per locale so each hreflang target has a matching URL in the index.
- How do you handle multi-tenant or multi-locale SEO on Next.js?
- Path-prefix locales (no subdomains), one set of canonical/hreflang tags, one sitemap per locale, and ISR with a manual purge channel. Ofrai ships six locales this way and Google indexes all of them without us coordinating TLS or HSTS across multiple origins.
- Does Next.js App Router work with next-intl?
- Yes. next-intl reads the locale from the URL prefix inside getRequestConfig, then exposes getTranslations and setRequestLocale to server components and a NextIntlClientProvider to client components. We migrated Ofrai in a week and the only client-side JS we ship for translation is the namespace we actually use on each page.