DEV Community

Multimedigital
Multimedigital

Posted on

Bilingual Next.js 16 + Sanity: the App Router patterns nobody documents

I run a small independent studio in Marrakech and I rebuilt our own site on Next.js 16 with Sanity, in French and English. The official docs cover the happy path well. What they don't cover is everything that breaks once you combine [locale] routing, a CMS, and a Sanity Studio mounted on the same domain.

Here are the five patterns that cost me real hours, with the actual code we ship in production.

1. Your middleware will fight your CMS admin route

We mount Sanity Studio at /admin. next-intl wants to prefix every route with a locale. Left alone, /admin becomes /en/admin and Studio breaks.

The fix is not to disable the middleware — it is to run your own logic first, then hand off:

import createMiddleware from "next-intl/middleware";
import { NextRequest, NextResponse } from "next/server";
import { routing } from "./src/i18n/routing";

const intlMiddleware = createMiddleware(routing);

export default function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // Sanity v5 shows a "Go to default workspace" screen on /admin.
  // Skip it and land on the real structure directly.
  if (pathname === "/admin" || pathname === "/admin/") {
    const url = req.nextUrl.clone();
    url.pathname = "/admin/structure/accueil";
    return NextResponse.redirect(url);
  }

  // Everything else goes through i18n
  return intlMiddleware(req);
}
Enter fullscreen mode Exit fullscreen mode

The matcher is the part people get wrong. You need public routes to hit i18n, /admin exactly to hit your redirect, and /admin/* to be left completely alone so Studio can route itself:

export const config = {
  matcher: [
    "/((?!api|_next|_vercel|admin/|.*\\..*).*)", // public routes → i18n
    "/admin",                                    // exact → redirect
  ],
};
Enter fullscreen mode Exit fullscreen mode

Note the trailing slash in admin/. Without it you exclude the exact /admin too, and your redirect never fires.

2. requestLocale is a promise now, and validation is not optional

In older next-intl you read a locale param. In the current App Router setup you await requestLocale and you must validate it yourself — otherwise a request for /de/anything tries to import a message file that doesn't exist and you get a runtime error instead of a 404.

import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";

export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested)
    ? requested
    : routing.defaultLocale;

  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  };
});
Enter fullscreen mode Exit fullscreen mode

hasLocale is the guard. It is three lines and it removes a whole class of 500s.

3. Import Link from your own navigation module, never from next/link

This is the single most common bug I see in bilingual App Router codebases. If you use next/link, your locale is silently dropped on navigation and users get bounced to the default language.

// src/i18n/navigation.ts
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";

export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing);
Enter fullscreen mode Exit fullscreen mode

Then everywhere in the app:

import { Link } from "@/src/i18n/navigation";<Link href="/services/web-development">Web development</Link>
Enter fullscreen mode Exit fullscreen mode

You write locale-free hrefs. The wrapper resolves /en/... or /fr/... for you. Add an ESLint rule banning next/link if you work with anyone else.

4. hreflang has to be emitted per locale, or Google serves the wrong language

This one is not a crash, which makes it worse: everything looks fine and you lose traffic quietly. I measured it on our own site — Google was ranking our English article on French queries, sending French readers to English copy.

Emit the alternates from generateMetadata in your [locale]/layout.tsx:

alternates: {
  canonical: `${SITE.url}/${locale}`,
  languages: {
    en: `${SITE.url}/en`,
    fr: `${SITE.url}/fr`,
    "x-default": `${SITE.url}/en`,
  },
},
Enter fullscreen mode Exit fullscreen mode

x-default is the one people skip. Without it, Google picks a default for you and it may not be the one you want.

If you want the full breakdown of how we diagnosed and fixed that language-mismatch problem with internal linking, I wrote it up here: how we push the right language version in Google.

5. Static params: generate both locales, or you ship half a site

With localePrefix: "always" every page exists twice. Your generateStaticParams has to reflect that, and so does your sitemap. Our build output looks like this:

├ ● /[locale]
│ ├ /en
│ └ /fr
├ ● /[locale]/journal/[slug]
│ ├ /en/journal/prix-site-web-maroc-2026
│ ├ /fr/journal/prix-site-web-maroc-2026
│ └ [+31 more paths]
Enter fullscreen mode Exit fullscreen mode

91 static pages from ~20 routes. If your build shows roughly half the number you expect, you are generating one locale and letting the other fall back to SSR — slower pages and inconsistent caching for no reason.

One more thing: AI crawlers need their own robots entries

Not strictly an i18n problem, but it bit us on the same site. Claude-Web is a deprecated user agent. If your robots.ts still lists it, you think you are allowing Anthropic's crawler and you are not. The current set:

{ userAgent: "GPTBot", allow: "/" },
{ userAgent: "ChatGPT-User", allow: "/" },
{ userAgent: "OAI-SearchBot", allow: "/" },
{ userAgent: "ClaudeBot", allow: "/" },
{ userAgent: "Claude-SearchBot", allow: "/" },
{ userAgent: "Claude-User", allow: "/" },
{ userAgent: "PerplexityBot", allow: "/" },
{ userAgent: "Perplexity-User", allow: "/" },
{ userAgent: "Google-Extended", allow: "/" },
Enter fullscreen mode Exit fullscreen mode

Pair it with an llms.txt route if you want answer engines to describe your site accurately rather than guess.

Takeaways

  • Run your own middleware logic before handing off to intlMiddleware, and be surgical with the matcher.
  • Guard requestLocale with hasLocale.
  • Never import Link from next/link in a localized app.
  • Emit hreflang and x-default per locale.
  • Check your build output page count against locales × routes.

We publish our stack decisions and our pricing openly — unusual in our market, but it saves everyone time. The full technical write-up on how we build these sites is on our studio's journal.

If you are running a bilingual App Router site and hit something I missed, tell me in the comments — I would rather learn it from you than from a production incident.

Top comments (0)