neptaystudio
Start a project
Insights/Engineering

Building a bilingual Next.js App Router site: i18n, hreflang, and structured data done right

June 9, 2026·12 min read·Neptay Studio

A practical walk-through of how we ship bilingual (EN/TR) Next.js App Router sites — locale routing, hreflang alternates, JSON-LD per locale, and the small middleware tricks that keep both languages first-class.

Most bilingual websites are monolingual sites with a translation layer bolted on. The English version is the one that ships first, the one with the working forms, and the one search engines learn first. The translated edition is a JSON file that fell off the back of a translation memory and a header dropdown that no one tested.

That isn't what we wanted to build. neptay.com runs in English and Turkish from the same content layer, with locale-aware routing, metadata, sitemaps, hreflang, and structured data. The Turkish edition isn't a retrofit — it's the same site in another voice. This article is the engineering walk-through: how we set it up on the Next.js App Router, what to put in middleware, how hreflang has to look for Google to trust it, and the small structural decisions that pay off later.

If you're shipping a small bilingual site — a studio page, a restaurant menu, a regional landing page — you can copy this pattern almost wholesale. If you're shipping a fifty-language storefront, this is the foundation; you'll just swap the dictionary for a CMS feed.

The shape of the URL space

There are essentially three options for how a bilingual Next.js site can lay out its URLs:

  1. 01Subdomain per locale (en.example.com, tr.example.com). Strong separation, but expensive to manage TLS and analytics for, and visually fragmenting for a small site.
  2. 02Country TLD per locale (example.com, example.com.tr). Excellent for geo-targeting, but only justified when you're truly running a Turkey-specific business — costly otherwise.
  3. 03Locale prefix per path (example.com/en/, example.com/tr/). A single domain, single deploy, single analytics property. This is what we use, and what most small bilingual sites should use.

The Next.js App Router handles locale prefixing cleanly because routes are file-system driven. The whole site lives under app/[locale]/, and every leaf page receives the locale as a route parameter. There's no special-cased home page, no hidden split between 'translated' and 'untranslated' routes — the locale is just another segment.

The directory layout we use

app/
  [locale]/
    layout.tsx       ← per-locale layout: nav, footer, hreflang metadata
    page.tsx         ← home
    services/page.tsx
    work/
      page.tsx       ← work index
      [slug]/page.tsx← case study
    studio/page.tsx
    contact/page.tsx
    insights/
      page.tsx
      [slug]/page.tsx
  layout.tsx         ← root layout: html lang, global JSON-LD
  sitemap.ts
  robots.ts

There are two layouts on purpose. The root layout owns the <html> element and injects the Organization + WebSite JSON-LD once. The locale layout owns the navigation, footer, and per-locale metadata. That split keeps the <html lang="…"> attribute honest — it reflects the locale you're actually viewing — and keeps the structured data global where it belongs.

Middleware: detecting and redirecting the right locale

When someone hits the bare domain, you have to decide: where do they go? You have three signals — the URL (if they typed /tr/ explicitly), the Accept-Language header (what the browser wants), and a cookie (what they chose last time). The rule we use is the boring one that works:

  1. 01If the URL already has a locale prefix, respect it. Set a cookie so we remember the choice.
  2. 02Else if there's a cookie from a previous visit, redirect to that locale.
  3. 03Else if the Accept-Language header maps to a locale we support, redirect there.
  4. 04Else fall through to the default locale (English).

We do this in middleware.ts, not in a layout — it's faster, runs at the edge, and avoids hydration mismatches. The middleware also sets a custom header (x-locale) that the root layout reads to set <html lang> correctly even before the URL has resolved.

// middleware.ts
import { NextResponse } from "next/server";
import { defaultLocale, isLocale, locales } from "@/lib/i18n";

const PUBLIC_FILE = /\.[\w]+$/;

export function middleware(req: Request & { nextUrl: URL }) {
  const { pathname } = req.nextUrl;
  if (pathname.startsWith("/_next") || PUBLIC_FILE.test(pathname)) return;

  const segments = pathname.split("/").filter(Boolean);
  const urlLocale = segments[0];

  if (isLocale(urlLocale)) {
    const res = NextResponse.next();
    res.headers.set("x-locale", urlLocale);
    res.cookies.set("NEXT_LOCALE", urlLocale, { path: "/" });
    return res;
  }

  const cookieLocale = req.headers.get("cookie")?.match(/NEXT_LOCALE=([^;]+)/)?.[1];
  const acceptLang = req.headers.get("accept-language") ?? "";
  const preferred = (cookieLocale && isLocale(cookieLocale))
    ? cookieLocale
    : (acceptLang.toLowerCase().startsWith("tr") ? "tr" : defaultLocale);

  const url = new URL(req.url);
  url.pathname = `/${preferred}${pathname === "/" ? "" : pathname}`;
  return NextResponse.redirect(url);
}

export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"] };

The cookie write matters. Without it, every visit re-runs the redirect logic, which is fine but wasteful — and worse, it makes language switching feel broken because hitting /tr/ on a returning English visitor would re-redirect them home.

Metadata, per locale, per route

Every page in App Router can export generateMetadata. For a bilingual site, three things need to happen in that function:

  1. 01Set title and description in the current locale.
  2. 02Set a canonical URL pointing at this exact path (no trailing slash issues, no protocol weirdness).
  3. 03Set hreflang alternates for every locale this page exists in, plus an x-default.

Next.js makes this trivial with the alternates.languages object. Here's the per-route shape:

export async function generateMetadata({ params }: { params: { locale: string } }): Promise<Metadata> {
  if (!isLocale(params.locale)) return {};
  const seo = WORK_SEO[params.locale];
  return {
    title: seo.title,
    description: seo.description,
    alternates: {
      canonical: `/${params.locale}/work`,
      languages: {
        en: "/en/work",
        tr: "/tr/work",
        "x-default": "/en/work",
      },
    },
    openGraph: { title: seo.title, description: seo.description, type: "website" },
  };
}

Two things people get wrong about hreflang:

  • Every alternate must point back. If /en/work lists /tr/work as an alternate, /tr/work must list /en/work too. Missing back-references make Google ignore the whole hreflang group.
  • x-default is not a locale. It's the URL for users whose language doesn't match any of yours. We point it at /en/work because English is our broadest fallback — not because English is more important.

If you have many pages, write a small helper that generates the alternates object for any path. We do this inline because we only have a handful of pages and the explicit version is easier to debug when you're staring at Search Console at 11pm.

Sitemaps and hreflang in the sitemap

Per-route hreflang in metadata is half the job. Google also reads hreflang from the sitemap, and the sitemap is the more authoritative signal because it covers the whole site at once. The App Router has a built-in sitemap.ts convention; here's what ours looks like:

// app/sitemap.ts
import type { MetadataRoute } from "next";
import { locales } from "@/lib/i18n";

const SITE = "https://neptay.com";
const PATHS = ["", "services", "work", "studio", "contact", "insights"] as const;

export default function sitemap(): MetadataRoute.Sitemap {
  const lastModified = new Date();
  return locales.flatMap((locale) =>
    PATHS.map((p) => ({
      url: `${SITE}/${locale}${p ? `/${p}` : ""}`,
      lastModified,
      changeFrequency: "monthly" as const,
      priority: p === "" ? 1 : 0.7,
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${SITE}/${l}${p ? `/${p}` : ""}`])
        ),
      },
    }))
  );
}

Notice that the sitemap emits one entry per (locale, path) combination, and each entry lists every locale alternate. This is repetitive — by design. Search engines are happier crawling explicit pairs than guessing from URL patterns.

Structured data: one base graph, route-specific extensions

Schema.org JSON-LD is the second leg of how Google understands a multilingual site. We split it into two layers:

  1. 01A base graph (Organization + WebSite) injected once by the root layout. This declares who the site belongs to, where the logo lives, and which languages the site is published in.
  2. 02Per-route schemas (CreativeWork, BreadcrumbList, Article, FAQPage) injected by each page. These are small JSON-LD blocks that describe what this specific page is.

Both have inLanguage set to the current locale. Don't skip this — without inLanguage, Google has to infer the language from the page text, which is slow and imperfect for short pages.

// lib/structured-data.ts
export const organizationLd = {
  "@type": "Organization",
  "@id": `${SITE_URL}/#organization`,
  name: "Neptay Studio",
  url: SITE_URL,
  logo: { "@type": "ImageObject", url: `${SITE_URL}/neptay-logo.png` },
  knowsLanguage: ["en", "tr"],
  areaServed: "Worldwide",
};

export function articleLd(input: ArticleInput) {
  return {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: input.headline,
    inLanguage: input.locale,            // ← critical for bilingual sites
    datePublished: input.datePublished,
    author: { "@id": `${SITE_URL}/#organization` },
    publisher: { "@id": `${SITE_URL}/#organization` },
    // …
  };
}

The dictionary pattern

Translation strings live in a single typed dictionary, exported as a const map keyed by locale. This is unfashionable — the modern advice is to use a framework like next-intl or react-intl with ICU message format. For a small site, the typed dictionary is faster, smaller, and catches missing translations at compile time:

export type Locale = "en" | "tr";
export const locales: Locale[] = ["en", "tr"];

export interface Dictionary {
  locale: Locale;
  nav: { services: string; work: string; studio: string; contact: string };
  // …
}

export const dictionaries: Record<Locale, Dictionary> = {
  en: { /* … */ },
  tr: { /* … */ },
};

If a Turkish translation is missing for a string, TypeScript complains during build. If a key gets added in English but not Turkish, the build fails. That's worth a lot when you have two languages — it means the only way to ship is to keep them in lockstep.

The pattern breaks at around twenty languages or when non-engineers need to edit copy. At that point, move the dictionary to a CMS and accept that translations will lag behind. For two languages and a studio site, plain TypeScript wins.

Static rendering, edge caching, and why this matters for SEO

All of the above is performance-neutral on a single page load. Where it matters is at scale. By keeping every page statically rendered (no runtime database lookups, no per-request translation calls), every locale of every page is a cacheable, edge-cached HTML document. First-byte time is whatever your CDN says it is — typically 30–80ms anywhere on the planet.

That matters for SEO for two reasons: Core Web Vitals weight TTFB and LCP heavily, and Google's crawler has a budget. A static, edge-cached page costs the crawler one cheap round-trip; a server-rendered page can cost ten times that. Crawl budget isn't a problem for a five-page studio site, but it becomes one fast for a multilingual blog with two hundred articles.

What we'd still improve

Honest list of what's still worth doing on a bilingual App Router site after the basics:

  • Per-locale OpenGraph images. Currently the OG image is the same across both locales; ideally the headline on the image is translated too.
  • Automatic translation drift detection. A CI step that diffs the EN and TR dictionary keys and fails on mismatch. Easy, valuable.
  • Per-locale 404 copy that actually understands which locale the visitor came from, not just the URL.
  • Language-switch component that keeps the user on the same page if a translation exists, and falls back gracefully (with a note) if it doesn't.

If you're scoping a bilingual site and want a second pair of eyes on the architecture — or you want us to build it — we're at hello@neptay.com.

Let's talk

Tell us about your project.

A short message is enough. We reply within 24 hours with honest next steps — even if it's not a fit.