---
title: "Next.js Multilingual App Tutorial 2026: UI + Content i18n"
description: "Build a multilingual Next.js 16 app: next-intl for UI strings, APITube for content in 60+ languages, working hreflang and a multilingual sitemap. Server Components, RTL, and Vercel Edge deploy — free tier included."
source: https://apitube.io/blog/post/nextjs-multilingual-news-app-tutorial
---

# 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:**

| Threshold | Cutoff | Why |
|---|---|---|
| Speakers in target market | ≥ 10M | Below 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 | ≥ 500 | Below this, the locale looks empty during slow news cycles |
| UI string translation cost | ≤ $200/mo per locale | Above 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

```bash
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

```bash
npm install next-intl
```

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

| Library | Bundle | RSC native | Message compile | Last release | Best for |
|---|---|---|---|---|---|
| **next-intl** | ~2 KB | ✅ | AOT (SWC) | Q1 2026 | Default 2026 choice |
| next-i18next | ~14 KB | ❌ Pages-only | Runtime | 2025 maintenance | Legacy Pages Router apps |
| Paraglide | ~0.5 KB | ✅ | AOT | Q1 2026 | Extreme bundle-conscious apps |
| Intlayer | ~3 KB | ✅ | JIT | Q1 2026 | Type-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`:

```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:

```ts
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`:

```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):

```ts
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`:

```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`:

```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`:

```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 call `revalidateTag('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`:

```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`:

```ts
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`:

```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

```bash
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.

```ts
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** — [apitube.io](https://apitube.io) — try it free, sentiment and entities included on every article
- **Documentation** — [docs.apitube.io](https://docs.apitube.io) — endpoints, parameters, response structure, integrations
- **Pricing** — [apitube.io/pricing](https://apitube.io/pricing) — all tiers
- **Related** — [React News Dashboard Tutorial](/blog/post/react-news-dashboard-tutorial) · [10 News API Filter Patterns](/blog/post/news-api-filter-patterns) · [News API Quick Start](/blog/post/news-api-quick-start-first-request)
