neptay
Toutes les réflexions

Ingénierie

Construire un site bilingue avec le Next.js App Router : i18n, hreflang et données structurées, dans les règles

Un guide pratique de la façon dont nous livrons des sites Next.js App Router bilingues (EN/TR) — routage par langue, liens alternatifs hreflang, JSON-LD par langue, et les petites astuces de middleware qui traitent les deux langues à égalité.

Dans cet article

La plupart des sites bilingues sont des sites monolingues sur lesquels on a greffé une couche de traduction. La version anglaise est celle qui sort en premier, celle dont les formulaires fonctionnent, celle que les moteurs de recherche découvrent en premier. L’édition traduite, elle, se résume à un fichier JSON tombé d’une mémoire de traduction et à un menu déroulant dans l’en-tête que personne n’a testé.

Ce n’est pas ce que nous voulions construire. neptay.com a démarré en anglais et en turc, et fonctionne aujourd’hui en dix langues à partir de la même couche de contenu, avec un routage, des métadonnées, des sitemaps, des hreflang et des données structurées qui tiennent compte de la langue. L’édition turque n’a pas été ajoutée après coup — c’est le même site, dans une autre voix. Cet article en détaille l’ingénierie : comment nous l’avons mis en place sur le Next.js App Router, ce qu’il faut mettre dans le middleware, à quoi doit ressembler le hreflang pour que Google s’y fie, et les petites décisions structurelles qui paient plus tard.

Si vous livrez un petit site bilingue — la page d’un studio, le menu d’un restaurant, une page de destination régionale — vous pouvez reprendre ce modèle presque tel quel. Si vous livrez une boutique en ligne en cinquante langues, c’est la fondation ; il vous suffira de remplacer le dictionnaire par un flux issu d’un CMS.

L’architecture des URL

Un site Next.js bilingue a essentiellement trois façons d’organiser ses URL :

  1. Un sous-domaine par langue (en.example.com, tr.example.com). Une séparation nette, mais coûteuse à gérer côté TLS et mesure d’audience, et visuellement fragmentée pour un petit site.
  2. Un domaine national par langue (example.com, example.com.tr). Excellent pour le ciblage géographique, mais justifié seulement si vous exploitez réellement une activité propre à la Turquie — coûteux sinon.
  3. Un préfixe de langue dans le chemin (example.com/en/, example.com/tr/). Un seul domaine, un seul déploiement, une seule propriété de mesure d’audience. C’est ce que nous utilisons, et ce que la plupart des petits sites bilingues devraient utiliser.

Le Next.js App Router gère proprement le préfixe de langue, car les routes découlent du système de fichiers. Tout le site vit sous app/[locale]/, et chaque page finale reçoit la locale comme paramètre de route. Pas de page d’accueil traitée à part, pas de séparation cachée entre routes « traduites » et « non traduites » — la locale n’est qu’un segment de plus.

L’arborescence que nous utilisons

Exemple de code
app/
  [locale]/
    layout.tsx       ← root layout: <html lang>, nav, footer, JSON-LD, hreflang
    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
    [...rest]/page.tsx ← catch-all → notFound()
    not-found.tsx    ← localized 404
  sitemap.ts
  robots.ts

Remarquez ce qui manque : il n’y a pas de app/layout.tsx. Le layout de la locale est le layout racine — il possède directement l’élément <html>, si bien que <html lang="…"> vient tout droit du paramètre de route. Pas d’astuce d’en-tête, pas de correctif côté client et, surtout, pas d’appel à headers(), qui exclurait en silence chaque route de la génération statique. Les sections qui vivent en dehors du système de langues (nos démos en ligne) ont leur propre layout racine — Next.js accepte plusieurs layouts racines, et la frontière de la locale est précisément l’endroit où s’en servir.

Middleware : détecter la bonne langue et rediriger

Quand quelqu’un arrive sur le domaine racine, il faut trancher : où l’envoyer ? Vous disposez de trois signaux — l’URL (s’il a tapé /tr/ explicitement), l’en-tête Accept-Language (ce que veut le navigateur) et un cookie (ce qu’il a choisi la dernière fois). La règle que nous appliquons est la plus banale, celle qui fonctionne :

  1. Si l’URL a déjà un préfixe de langue, on le respecte. On pose un cookie pour mémoriser le choix.
  2. Sinon, s’il existe un cookie d’une visite précédente, on redirige vers cette langue.
  3. Sinon, si l’en-tête Accept-Language correspond à une langue prise en charge, on redirige vers elle.
  4. Sinon, on se rabat sur la langue par défaut (l’anglais).

Nous le faisons dans middleware.ts, pas dans un layout — c’est plus rapide, cela s’exécute sur l’edge et évite les incohérences d’hydratation. Voici une version simplifiée du middleware qui tourne sur neptay.com :

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

const LOCALE_COOKIE = "neptay.locale";

function pickLocaleFromAcceptLanguage(header: string | null): "en" | "tr" {
  if (!header) return defaultLocale;
  const lower = header.toLowerCase();
  if (/(^|[,;\s])tr(-|;|,|$)/.test(lower)) return "tr";
  return defaultLocale;
}

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

  // Live demos live outside the locale system — they handle their own language.
  if (pathname === "/demo" || pathname.startsWith("/demo/")) {
    return NextResponse.next();
  }

  const matchedLocale = locales.find(
    (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
  );
  if (matchedLocale) {
    const res = NextResponse.next();
    // Remember the locale so the next bare-domain visit lands on it.
    if (req.cookies.get(LOCALE_COOKIE)?.value !== matchedLocale) {
      res.cookies.set(LOCALE_COOKIE, matchedLocale, {
        path: "/",
        maxAge: 60 * 60 * 24 * 365,
        sameSite: "lax",
      });
    }
    return res;
  }

  // Honour the user's manual choice if it was already remembered.
  const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
  const remembered = cookieLocale && isLocale(cookieLocale) ? cookieLocale : null;
  const detected = remembered ?? pickLocaleFromAcceptLanguage(req.headers.get("accept-language"));

  const url = req.nextUrl.clone();
  url.pathname = `/${detected}${pathname === "/" ? "" : pathname}`;
  return NextResponse.redirect(url);
}

export const config = {
  // Skip Next internals + static assets so they aren't redirected.
  matcher: [
    "/((?!_next/|api/|favicon\\.|robots\\.txt|sitemap\\.xml|.*\\.(?:png|svg|jpg|jpeg|gif|webp|ico|txt|xml)).*)",
  ],
};

Deux détails font toute la différence. La vérification d’Accept-Language analyse réellement la liste de l’en-tête, séparée par des virgules, au lieu d’un simple startsWith — un navigateur qui envoie « en-GB,tr;q=0.8 » ne doit pas être traité comme turc simplement parce que « tr » y apparaît quelque part. Et le cookie est écrit à chaque visite d’une URL préfixée par une langue, pas seulement lors d’un changement manuel, si bien que la visite suivante sur le domaine racine atterrit sur la langue lue en dernier. Sans cela, le changement de langue paraît cassé : un visiteur qui revient après avoir explicitement choisi le turc est renvoyé vers la langue devinée par Accept-Language.

Des métadonnées par langue et par route

Dans l’App Router, chaque page peut exporter generateMetadata. Pour un site bilingue, cette fonction doit faire trois choses :

  1. Définir le titre et la description dans la langue courante.
  2. Définir une URL canonique qui pointe vers ce chemin exact (sans problème de slash final ni bizarrerie de protocole).
  3. Déclarer les alternatives hreflang pour chaque langue dans laquelle la page existe, plus un x-default.

Next.js rend cela trivial grâce à l’objet alternates.languages. Voici la forme, route par route :

Exemple de codeTypeScript
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" },
  };
}

Deux erreurs fréquentes avec hreflang :

  • Chaque alternative doit renvoyer vers l’autre. Si /en/work déclare /tr/work comme alternative, /tr/work doit aussi déclarer /en/work. Sans ces liens de retour, Google ignore tout le groupe hreflang.
  • x-default n’est pas une langue. C’est l’URL destinée aux utilisateurs dont la langue ne correspond à aucune des vôtres. Nous le faisons pointer vers /en/work parce que l’anglais est notre solution de repli la plus large — pas parce que l’anglais compte davantage.

Si vous avez beaucoup de pages, écrivez une petite fonction utilitaire qui génère l’objet alternates pour n’importe quel chemin. Nous l’écrivons en ligne, car nous n’avons qu’une poignée de pages et la version explicite est plus facile à déboguer quand on fixe la Search Console à 23 h.

Sitemaps et hreflang dans le sitemap

Déclarer le hreflang route par route dans les métadonnées ne fait que la moitié du travail. Google lit aussi le hreflang dans le sitemap, et le sitemap fait davantage autorité, car il couvre tout le site d’un coup. L’App Router intègre une convention sitemap.ts ; voici à quoi ressemble la nôtre :

app/sitemap.tsTypeScript
// 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}` : ""}`])
        ),
      },
    }))
  );
}

Remarquez que le sitemap produit une entrée par combinaison (langue, chemin), et que chaque entrée liste toutes les alternatives de langue. C’est répétitif — et c’est voulu. Les moteurs de recherche préfèrent explorer des paires explicites plutôt que deviner à partir de motifs d’URL.

Données structurées : un graphe de base, des extensions par route

Le JSON-LD Schema.org est le deuxième pilier de la compréhension d’un site multilingue par Google. Nous le répartissons en deux couches :

  1. Un graphe de base (Organization + WebSite), injecté une seule fois par le layout racine. Il déclare à qui appartient le site, où se trouve le logo et dans quelles langues le site est publié.
  2. Des schémas par route (CreativeWork, BreadcrumbList, Article, FAQPage), injectés par chaque page. Ce sont de petits blocs JSON-LD qui décrivent ce qu’est précisément cette page.

Les deux ont inLanguage réglé sur la langue courante. Ne faites pas l’impasse — sans inLanguage, Google doit déduire la langue du texte de la page, ce qui est lent et imparfait pour les pages courtes.

lib/structured-data.tsTypeScript
// lib/structured-data.ts
export const organizationLd = {
  "@type": "Organization",
  "@id": `${SITE_URL}/#organization`,
  name: "Neptay",
  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` },
    // …
  };
}

Le modèle du dictionnaire

Les chaînes traduites vivent dans un seul dictionnaire typé, exporté sous forme de map constante indexée par langue. Ce n’est pas à la mode — le conseil actuel est d’utiliser un framework comme next-intl ou react-intl avec le format de messages ICU. Pour un petit site, le dictionnaire typé est plus rapide, plus léger, et repère les traductions manquantes dès la compilation :

Exemple de codeTypeScript
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: { /* … */ },
};

S’il manque la traduction turque d’une chaîne, TypeScript le signale pendant le build. Si une clé est ajoutée en anglais mais pas en turc, le build échoue. Cela vaut de l’or quand on a deux langues — la seule façon de livrer est de les garder parfaitement synchronisées.

Ce modèle atteint ses limites vers une vingtaine de langues, ou quand des non-développeurs doivent modifier les textes. À ce stade, déplacez le dictionnaire dans un CMS et acceptez que les traductions prennent du retard. Pour deux langues et un site de studio, le TypeScript pur l’emporte.

Rendu statique, cache sur l’edge et enjeux SEO

Tout ce qui précède est neutre en performance sur le chargement d’une seule page. C’est à grande échelle que cela compte. En gardant chaque page en rendu statique (aucune requête en base à l’exécution, aucun appel de traduction à chaque requête), chaque version linguistique de chaque page devient un document HTML mis en cache sur l’edge. Le temps jusqu’au premier octet est celui de votre CDN — généralement 30 à 80 ms, partout sur la planète.

Cela compte pour le SEO pour deux raisons : les Core Web Vitals accordent un poids important au TTFB et au LCP, et le robot d’exploration de Google a un budget. Une page statique en cache sur l’edge coûte au robot un aller-retour bon marché ; une page rendue côté serveur peut coûter dix fois plus. Le budget d’exploration n’est pas un problème pour un site de studio de cinq pages, mais il le devient vite pour un blog multilingue de deux cents articles.

Ce que nous améliorerions encore

La liste honnête de ce qui vaut encore la peine d’être fait sur un site App Router bilingue, une fois les bases posées :

  • Des images OpenGraph par langue. Pour l’instant, l’image OG est la même dans les deux langues ; idéalement, le titre sur l’image serait lui aussi traduit.
  • La détection automatique des écarts de traduction. Une étape de CI qui compare les clés des dictionnaires EN et TR et échoue en cas de différence. Simple, et précieux.
  • Un sélecteur de langue qui garde l’utilisateur sur la même page si une traduction existe, et se replie avec élégance (avec une mention) sinon.

Si vous cadrez un site bilingue et souhaitez un regard extérieur sur l’architecture — ou si vous voulez que nous le construisions — écrivez-nous à hello@neptay.com.

Neptay Media & Technology Services

Parlons-en

Vous préparez un projet similaire ?

Les méthodes décrites dans ces articles sont celles que nous appliquons aux projets de nos clients — logiciels, automatisation IA, contenu, réseaux sociaux et production de lives. Dites-nous ce que vous avez en tête ; nous répondons sous 24 heures.