Next.js Multilingual App Tutorial 2026: UI + Content i18n

Jacob Partington

Jacob Partington

·

24 mins læse

Next.js Multilingual App Tutorial 2026: UI + Content i18n

Build a Multilingual Next.js News App: UI + Content in 60+ Languages

A multilingual Next.js news app is a Next.js App Router application that localizes both interface strings and dynamic content across multiple locales — using next-intl for UI translation, a [locale] dynamic segment for routing, middleware for locale detection, and a multilingual content source like APITube to deliver articles natively written in the user's language.

UI i18n is solved. next-intl ships in ~2 KB, handles Server Components, and translates strings cleanly. Content i18n isn't. Most "multilingual Next.js" tutorials translate five hardcoded UI strings and call it done. Real apps need three layers in three languages: UI chrome, dynamic data, and SEO metadata.

Unlike single-locale i18n setups that translate only static UI strings, this two-layer approach localizes runtime content too — which means non-English users see articles written natively in their language, not Google-translated approximations.

This tutorial wires all three into a single Next.js 16 App Router app. We'll use next-intl for the UI, APITube's language.code filter for articles in 71 ISO 639-1 languages, and an auto-generated app/sitemap.ts for hreflang. By the end you'll have a working multilingual news reader you can deploy to Vercel.

This article is for full-stack developers who already know Next.js App Router and React Server Components. If you're new to Next.js, start with the official tutorial first.

Key takeaways

  • Use next-intl (~2 KB, RSC-native) for UI string translation in Next.js 16 App Router.
  • Use APITube's language.code parameter (71 ISO 639-1 codes) to source articles natively in each locale — don't auto-translate news bodies.
  • Emit alternates.languages with an x-default entry in generateMetadata, plus a multilingual app/sitemap.ts, for SEO-correct hreflang.
  • Validate locales before translating: ≥10M speakers, ≥1% expected traffic share, ≥500 articles/day in language, and ≤$200/mo translation budget per locale.

What you'll build

A news reader with locale-prefixed URLs (/en, /es, /fr, ...), translated UI chrome via next-intl, articles fetched live from APITube in the matching language, and SEO-correct hreflang tags + a multilingual sitemap. Total build time: ~90 minutes.

The repository structure we're targeting:

app/
  [locale]/
    layout.tsx
    page.tsx
  sitemap.ts
i18n/
  routing.ts
  request.ts
messages/
  en.json
  es.json
  fr.json
  de.json
  pt.json
  ar.json
  ja.json
lib/
  news.ts
middleware.ts
next.config.ts

Prerequisites

  • Node.js ≥ 20
  • An APITube API key — grab one at apitube.io; the free tier gives 30 requests per 30 minutes, enough to follow this tutorial
  • Familiarity with React Server Components

Disclosure: APITube is the news API I work on. Sentiment, entities, and language.code filtering are included on every article in 71 languages.

Choosing your launch locales

Before any code: which locales actually ship? Most teams over-translate. Translation work is 30–60 hours per locale per major release. Don't pay that cost speculatively.

A locale qualifies for a multilingual Next.js launch only if it clears all four numeric thresholds:

ThresholdCutoffWhy
Speakers in target market≥ 10MBelow this, organic discovery cost dominates
Expected traffic share≥ 1% (or 4 weeks of ≥ 5% session share for new locales)Below 1%, the locale won't pay back QA + translation cost
APITube articles/day in language≥ 500Below this, the locale looks empty during slow news cycles
UI string translation cost≤ $200/mo per localeAbove this, the locale fails ROI without a clear revenue tie

Starter set for a global news reader (all four thresholds clear comfortably):

  • en — default fallback, ~1.5B speakers
  • es — Latin America + Spain, ~560M
  • fr — France + Africa francophone, ~310M
  • de — DACH region, ~135M
  • pt-BR — Brazil, ~230M
  • ar — MENA, ~420M (RTL)
  • ja — Japan, ~125M (high purchasing power, low competition for English-origin SaaS)

Don't translate before you validate. If you're shipping an MVP, ship in en only. Add the second locale once English earns ≥ 5% session share from non-anglophone countries for 4 consecutive weeks. Premature i18n is the #1 source of stale translation files.

Step 1 — Initialize Next.js 16 with App Router

npx create-next-app@latest news-i18n --typescript --tailwind --app
cd news-i18n

Skip ESLint and src/ for brevity.

Step 2 — Install next-intl and configure routing

npm install next-intl

Why next-intl? Four 2026 i18n libraries are viable. Pick by the constraint that bites first:

LibraryBundleRSC nativeMessage compileLast releaseBest for
next-intl~2 KBAOT (SWC)Q1 2026Default 2026 choice
next-i18next~14 KB❌ Pages-onlyRuntime2025 maintenanceLegacy Pages Router apps
Paraglide~0.5 KBAOTQ1 2026Extreme bundle-conscious apps
Intlayer~3 KBJITQ1 2026Type-safe per-component messages

For 95% of App Router apps, next-intl wins on bundle, RSC support, and ahead-of-time message compilation. We use it here.

Define the locales in i18n/routing.ts:

import { defineRouting } from 'next-intl/routing';

export const routing = defineRouting({
  locales: ['en', 'es', 'fr', 'de', 'pt', 'ar', 'ja'],
  defaultLocale: 'en',
  localePrefix: 'always',
});

localePrefix: 'always' produces /en/, /es/, /fr/ — best for SEO and clearest URLs. Don't hide the default locale.

Now i18n/request.ts — this loads the message file per request:

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

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;
  if (!locale || !routing.locales.includes(locale as 'en')) {
    locale = routing.defaultLocale;
  }
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

Wire the plugin in next.config.ts:

import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin('./i18n/request.ts');

export default withNextIntl({});

Step 3 — Middleware for locale detection and redirects

middleware.ts (root):

import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';

export default createMiddleware(routing);

export const config = {
  matcher: ['/((?!api|_next|.*\\..*).*)'],
};

The middleware reads the Accept-Language header on a bare / request, picks the closest supported locale, and 307-redirects to /en, /es, etc. Bots crawling without an Accept-Language header land on the default locale.

Step 4 — Translate UI strings

Create messages/en.json:

{
  "Header": {
    "title": "Global News",
    "switchLocale": "Language"
  },
  "News": {
    "loading": "Loading articles…",
    "empty": "No articles in this language right now.",
    "publishedAt": "Published {date}"
  }
}

Mirror the file for each locale (es.json, fr.json, de.json, pt.json, ar.json, ja.json). Same keys, translated values.

Now app/[locale]/layout.tsx:

import { NextIntlClientProvider, hasLocale } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  if (!hasLocale(routing.locales, locale)) notFound();
  setRequestLocale(locale);

  const dir = locale === 'ar' ? 'rtl' : 'ltr';

  return (
    <html lang={locale} dir={dir}>
      <body>
        <NextIntlClientProvider>{children}</NextIntlClientProvider>
      </body>
    </html>
  );
}

Two non-obvious lines: setRequestLocale(locale) enables static rendering (without it, every page becomes dynamic). dir={dir} flips Arabic to RTL — the smallest correct RTL implementation in App Router.

Step 5 — Wire up APITube for multilingual news content

Here's where most tutorials stop and where most production apps actually start. Translating 50 UI strings is easy. Sourcing 50 articles in Japanese, Arabic, and Portuguese is the part nobody covers.

Unlike translation APIs (Google Translate, DeepL) that convert one source language into many targets and break factual accuracy on numbers and quotes, APITube indexes news that was originally written in each language by native sources — which means the article you serve a Japanese user was reported in Japan in Japanese, not translated from English at request time.

APITube indexes news in 71 ISO 639-1 languages:

  • European: en, es, fr, de, pt, it, nl, ru, pl, sv, no, da, fi, el, cs, ro, hu, bg, hr, sk, sl, lt, lv, et
  • Asian: zh, ja, ko, hi, bn, ta, te, ur, vi, th, id, ms, fil
  • MENA: ar, fa, he, tr
  • African: sw, am
  • Other: la, cy, ga, plus 18 more regional codes

Filter by language.code to get articles natively written in the target language. Don't auto-translate news bodies — auto-translation creates duplicate-content SEO issues, factual drift on numbers and quotes, and embarrassing translation artefacts that make the app feel broken.

lib/news.ts:

type Article = {
  title: string;
  description: string;
  href: string;
  image: string | null;
  published_at: string;
  source: { domain: string };
  language: { code: string; name: string };
};

type NewsResponse = {
  status: string;
  results: Article[];
  has_next_pages: boolean;
};

export async function getNewsByLocale(locale: string): Promise<Article[]> {
  const url = new URL('https://api.apitube.io/v1/news/everything');
  url.searchParams.set('language.code', locale);
  url.searchParams.set('per_page', '20');

  const res = await fetch(url, {
    headers: { 'X-API-Key': process.env.APITUBE_KEY! },
    next: { revalidate: 600, tags: [`news-${locale}`] },
  });

  if (!res.ok) {
    if (locale !== 'en') return getNewsByLocale('en');
    throw new Error(`APITube ${res.status}`);
  }

  const data: NewsResponse = await res.json();
  return data.results;
}

Three details that matter:

  1. Locale in the URL, not the headers. Keeps the cache key clean. Same URL = same cache entry across requests.
  2. revalidate: 600 — 10-minute stale-while-revalidate. News goes stale fast; faster than blog posts, slower than tickers.
  3. Tag-based invalidation[\news-${locale}`]lets you callrevalidateTag('news-fr')` from a webhook to refresh just one locale.

The fallback to en on error is deliberate: the user gets something rather than an empty page. Log the failure on the server so you know which locales are flaky.

Step 6 — Server Component data fetching with locale-aware caching

app/[locale]/page.tsx:

import { getTranslations, setRequestLocale } from 'next-intl/server';
import { getNewsByLocale } from '@/lib/news';

export default async function Home({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  setRequestLocale(locale);

  const t = await getTranslations('News');
  const articles = await getNewsByLocale(locale);

  if (articles.length === 0) {
    return <p className="p-6">{t('empty')}</p>;
  }

  return (
    <main className="mx-auto max-w-3xl p-6">
      <ul className="space-y-6">
        {articles.map((a) => (
          <li key={a.href} className="border-b pb-4">
            <a href={a.href} target="_blank" rel="noopener" className="block">
              <h2 className="text-lg font-semibold">{a.title}</h2>
              <p className="mt-1 text-sm text-gray-600">{a.description}</p>
              <p className="mt-2 text-xs text-gray-400">
                {a.source.domain} · {t('publishedAt', { date: new Date(a.published_at).toLocaleDateString(locale) })}
              </p>
            </a>
          </li>
        ))}
      </ul>
    </main>
  );
}

getTranslations('News') runs in the Server Component without React hooks. setRequestLocale(locale) keeps the page statically rendered when the locale is in generateStaticParams.

RSC cache pitfall. If you ever move language.code out of the URL into a request header or a cookie, the Next.js fetch cache stops differentiating locales. Two requests with different locales but the same URL collide on the same cache entry, and one locale silently overwrites the other. Always keep locale-discriminating values in the URL or in next: { tags }.

Note also the use of Tailwind logical classes is fine here, but if you wanted the layout to adapt directionally for Arabic, use ms- / me- (margin-start/end) instead of ml- / mr-. Tailwind v4 ships these by default.

Step 7 — hreflang alternate links and a multilingual sitemap

This step is the SEO unlock. Most tutorials skip it; doing it right means your /es/page shows up in Google's Spanish index without you fighting the algorithm.

Generate per-page hreflang in generateMetadata:

import type { Metadata } from 'next';
import { routing } from '@/i18n/routing';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}): Promise<Metadata> {
  const { locale } = await params;
  const base = 'https://news.example.com';

  const languages: Record<string, string> = {};
  for (const l of routing.locales) languages[l] = `${base}/${l}`;
  languages['x-default'] = `${base}/${routing.defaultLocale}`;

  return {
    title: 'Global News',
    alternates: {
      canonical: `${base}/${locale}`,
      languages,
    },
  };
}

x-default is not optional. Google treats its absence as ambiguous when multiple locales serve overlapping content; emit it explicitly, pointing to your fallback.

Now the multilingual sitemap. app/sitemap.ts:

import type { MetadataRoute } from 'next';
import { routing } from '@/i18n/routing';

const base = 'https://news.example.com';
const pages = ['', '/about'];

export default function sitemap(): MetadataRoute.Sitemap {
  return pages.flatMap((page) =>
    routing.locales.map((locale) => {
      const languages: Record<string, string> = {};
      for (const l of routing.locales) languages[l] = `${base}/${l}${page}`;

      return {
        url: `${base}/${locale}${page}`,
        lastModified: new Date(),
        alternates: { languages },
      };
    })
  );
}

This emits one URL per locale × page, each with <xhtml:link rel="alternate"> entries for every other locale. Submit https://news.example.com/sitemap.xml to Search Console; Google indexes each locale separately and shows the right one to the right user.

Step 8 — Deploy to Vercel with Edge Middleware

vercel --prod

next-intl's middleware ships to the Vercel Edge Runtime by default — locale detection happens at the edge in 5–15 ms, no cold starts. Add APITUBE_KEY to Vercel's environment variables; you're live.

For geo-aware default redirects (a French visitor lands on /fr even with Accept-Language: en-US), wrap the next-intl middleware. Note the tradeoff: geo-routing breaks shareable URLs — a colleague in France sharing /en/article gets redirected to /fr/article, possibly seeing a different (or missing) article. Skip geo-routing if URL-sharing matters more than first-visit convenience.

import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import type { NextRequest } from 'next/server';

const intlMiddleware = createMiddleware(routing);
const country2locale: Record<string, string> = { FR: 'fr', DE: 'de', ES: 'es', BR: 'pt', JP: 'ja' };

export default function middleware(req: NextRequest) {
  if (req.nextUrl.pathname === '/') {
    const country = req.headers.get('x-vercel-ip-country');
    const target = country2locale[country ?? ''] ?? routing.defaultLocale;
    return Response.redirect(new URL(`/${target}`, req.url), 307);
  }
  return intlMiddleware(req);
}

Common pitfalls

  • Forgetting setRequestLocale(locale). Without it, every page becomes dynamic and revalidate stops working.
  • Mismatched message keys.next-intl throws at runtime if messages/fr.json is missing a key that en.json has. Add a CI check that compares JSON keys across locales.
  • Cookie-based locale switching. Cheap to implement, terrible for SEO — Google sees one URL, can't index per-locale.
  • Auto-translating news bodies. Causes duplicate content and factual drift. Source articles natively via APITube's language.code instead.

FAQ

How do I add multiple languages to my Next.js app?

To add multiple languages to a Next.js App Router app, install next-intl, define locales in i18n/routing.ts, add a [locale] dynamic segment under app/, and wire middleware for locale detection. Translate UI strings in messages/{locale}.json. For dynamic content, fetch from a multilingual API like APITube using language.code=${locale} in the request URL — UI and content are two distinct layers that need separate handling.

What is the best i18n library for Next.js in 2026?

The best i18n library for Next.js in 2026 is next-intl for ~95% of App Router apps — it ships in ~2 KB, supports React Server Components natively, and compiles messages ahead-of-time via the SWC plugin. Use next-i18next only on legacy Pages Router apps. Pick Paraglide if you're optimizing for sub-1 KB bundles, or Intlayer if you want type-safe per-component messages.

Does Next.js 16 App Router support i18n natively?

No, Next.js 16 App Router does not support i18n natively. Next.js removed the built-in i18n config when the App Router shipped — that config worked only with the Pages Router. In App Router, use a [locale] dynamic segment plus middleware plus a library (next-intl, Paraglide, or Intlayer) to handle routing, message loading, and metadata.

How do I localize content (not just UI) in Next.js?

Content localization in Next.js is a separate layer from UI localization. UI strings live in messages/{locale}.json files loaded by next-intl. Dynamic content comes from a multilingual data source — for news, APITube's /v1/news/everything?language.code=${locale} returns articles natively written in 71 languages. Source content natively rather than auto-translating, because automated translation introduces duplicate-content SEO issues and factual drift on numbers and quotes.

How do I add hreflang tags in Next.js App Router?

To add hreflang tags in Next.js App Router, return alternates.languages from generateMetadata in your locale layout, mapping each locale code to its absolute URL. Always include an x-default entry pointing to your fallback locale — Google treats its absence as ambiguity. For the sitemap, build app/sitemap.ts to iterate locales × pages and emit alternates.languages per URL; Search Console picks up the alternates automatically.

Next steps

You've got a working multilingual news app. From here:

  1. Add a locale switcher component (Server Component reading useLocale() plus <Link href="/" locale="es">).
  2. Wire revalidateTag('news-${locale}') to a webhook so editors can refresh one locale on demand.
  3. Filter APITube by category or sentiment per locale (category.id=medtop:04000000&sentiment.overall.polarity=positive) for vertical-specific feeds.

Try APITube free at apitube.io — the free tier covers everything in this tutorial. If you're shipping production traffic across 7+ locales, the Growth tier raises the rate limit to 1,000 requests / 30 min.

Resources

APITube - News API

Relaterede artikler

React News Dashboard Tutorial 2026: SSE + TypeScript
Developer Guides

React News Dashboard Tutorial 2026: SSE + TypeScript

Build a real-time React news dashboard with TypeScript and Server-Sent Events. Full code, 429-safe fetch hook, sentiment filters, Vercel deploy.

10 News API Filter Patterns You Should Know (2026)
Developer Guides

10 News API Filter Patterns You Should Know (2026)

10 runnable news API filter patterns — title search, dates, entities, sentiment, sources, and compound queries — with curl, Python, and JSON examples plus the gotcha that wastes your first day.

News API Quick Start: Your First Request in 5 Minutes
Developer Guides

News API Quick Start: Your First Request in 5 Minutes

Hit a real news API endpoint in 5 minutes — curl, JavaScript, and Python side by side, with an annotated JSON response and fixes for the 401/429 errors you'll actually hit.

Telegram Nyheds-bot i Python (2026): aiogram APScheduler
Developer Guides

Telegram Nyheds-bot i Python (2026): aiogram APScheduler

Byg en Telegram nyheds-bot i Python med aiogram 3.27, APScheduler og APITube. Async, sentiment-filtreret, automatisk post til kanal — fuld kode Docker.

Vi bruger cookies

Ved at klikke på" Accepter " accepterer du Lagring af cookies på din enhed til funktion og analyse.