Next.js i18n at Scale: 30 Languages, 91K Pages, Vercel ISR
Last year, we shipped a Next.js project that still makes me a little nervous when I think about it. Thirty languages. Over 91,000 statically generated pages. Vercel ISR keeping everything fresh. The kind of project where one wrong architectural decision means you're staring at 4-hour builds, $800/month hosting bills, or -- worst case -- a site that just doesn't work in Korean.
This is the story of how we got it right (and the parts where we didn't, at first). If you're building a large-scale internationalized Next.js application and wondering whether ISR can actually handle it in production, this article is for you.

Table of Contents
- The Problem: Why 91K Pages Is a Different Beast
- Architecture Decisions We Made Early
- Setting Up Next.js i18n for 30 Locales
- The ISR Strategy That Actually Worked
- Content Pipeline and Headless CMS Integration
- Performance Results and Core Web Vitals
- Cost Breakdown on Vercel
- Mistakes We Made and How We Fixed Them
- When to Use This Stack (and When Not To)
- FAQ
The Problem: Why 91K Pages Is a Different Beast
Let me set the stage. The client was an enterprise e-commerce brand expanding into 30 markets. Each market needed:
- Localized product pages (~2,800 products × 30 locales = 84,000 pages)
- Category pages (~120 categories × 30 locales = 3,600 pages)
- CMS-driven marketing pages (~120 × 30 = 3,600 pages)
- Total: approximately 91,200 unique URLs
With plain getStaticPaths and full static generation, the initial build was going to take somewhere between 3 and 5 hours. That's not a typo. We benchmarked early prototypes and watched the number climb. Every deploy would mean hours of downtime risk, and the content team wanted to publish updates multiple times per day.
SSR wasn't an option either. The client's traffic patterns showed massive spikes during sales events -- we're talking 50K concurrent users. Server-rendering 91K possible page variants under that load would require serious compute and introduce latency that kills conversion rates.
ISR was the answer. But ISR at this scale has its own set of challenges that the Next.js docs don't really prepare you for.
Architecture Decisions We Made Early
Before writing a single line of i18n code, we made three architectural decisions that saved us months of pain later.
Decision 1: Subpath Routing, Not Domains
Next.js supports two i18n strategies: subpath routing (/fr/products/...) and domain routing (fr.example.com). We chose subpath routing. Here's why:
| Factor | Subpath Routing | Domain Routing |
|---|---|---|
| DNS/SSL complexity | Single domain | 30 domains/subdomains to manage |
| Vercel deployment | One project | One project (but domain config overhead) |
| SEO link equity | Consolidated on one domain | Split across domains |
| CDN cache efficiency | Better (shared edge cache) | Fragmented |
| Analytics setup | Simpler | 30 properties or complex filtering |
For most projects under 50 locales, subpath routing is the move. Domain routing makes sense when you need country-specific TLDs for legal reasons or when your markets have fundamentally different content architectures.
Decision 2: next-intl Over next-i18next
We evaluated both libraries extensively. In 2025, next-intl (v4.x) has become the stronger choice for App Router projects, though we were on Pages Router for this build. Even on Pages Router, next-intl gave us:
- Better TypeScript support with type-safe message keys
- Smaller client bundle (about 2.1KB gzipped vs ~5KB for next-i18next)
- Native support for ICU message format (plurals, gender, number formatting)
- Simpler configuration for ISR pages
Decision 3: Partial Static Generation + ISR
This was the big one. Instead of trying to statically generate all 91K pages at build time, we pre-built only the highest-traffic pages (about 8,000) and let ISR handle the rest on-demand.
// pages/[locale]/products/[slug].tsx
export async function getStaticPaths() {
// Only pre-generate top 100 products × top 5 locales
const topProducts = await getTopProducts(100);
const primaryLocales = ['en', 'de', 'fr', 'es', 'ja'];
const paths = topProducts.flatMap(product =>
primaryLocales.map(locale => ({
params: { slug: product.slug, locale },
}))
);
return {
paths,
fallback: 'blocking', // ISR handles everything else
};
}
This brought our build time from 3+ hours down to about 12 minutes. The remaining 83,000 pages get generated on first request and cached at the edge.

Setting Up Next.js i18n for 30 Locales
The Next.js built-in i18n config in next.config.js handles locale detection and routing. Here's what our config looked like (abbreviated):
// next.config.js
const nextConfig = {
i18n: {
locales: [
'en', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'pl', 'cs', 'da',
'sv', 'fi', 'nb', 'hu', 'ro', 'bg', 'hr', 'sk', 'sl', 'el',
'tr', 'ja', 'ko', 'zh-CN', 'zh-TW', 'th', 'vi', 'id', 'ms', 'ar'
],
defaultLocale: 'en',
localeDetection: false, // We handle this ourselves
},
};
A couple things to note here. We disabled localeDetection because the built-in detection (based on Accept-Language headers) was causing issues with ISR caching. When Vercel's CDN caches a page, the locale needs to be deterministic from the URL, not from headers. Letting Next.js auto-redirect based on browser language meant cache misses and inconsistent behavior.
Instead, we built a custom locale detection middleware that runs on the root path only:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const SUPPORTED_LOCALES = ['en', 'de', 'fr', /* ... */];
const DEFAULT_LOCALE = 'en';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Only redirect on root path
if (pathname === '/') {
const acceptLanguage = request.headers.get('accept-language') || '';
const preferred = acceptLanguage.split(',')[0]?.split('-')[0] || DEFAULT_LOCALE;
const locale = SUPPORTED_LOCALES.includes(preferred) ? preferred : DEFAULT_LOCALE;
return NextResponse.redirect(new URL(`/${locale}`, request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/'],
};
Translation File Structure
With 30 languages, translation file management becomes a real concern. We organized translations by namespace:
messages/
├── en/
│ ├── common.json
│ ├── product.json
│ ├── checkout.json
│ └── marketing.json
├── de/
│ ├── common.json
│ ├── product.json
│ └── ...
└── ar/
└── ...
Total translation payload across all languages was about 4.2MB. But because we load translations per-page using getStaticProps, each individual page only loads 15-40KB of translation data for its locale and namespace. That's critical -- you don't want to ship all 30 locales to the client.
export async function getStaticProps({ locale }: GetStaticPropsContext) {
return {
props: {
messages: {
...(await import(`../../messages/${locale}/common.json`)).default,
...(await import(`../../messages/${locale}/product.json`)).default,
},
},
revalidate: 300, // ISR: revalidate every 5 minutes
};
}
RTL Support for Arabic
Arabic was the only RTL language in our set. We handled it with a simple layout wrapper:
const direction = locale === 'ar' ? 'rtl' : 'ltr';
return (
<html lang={locale} dir={direction}>
<body className={direction === 'rtl' ? 'font-arabic' : 'font-sans'}>
{children}
</body>
</html>
);
Plus Tailwind's rtl: variant for spacing and layout adjustments. This worked surprisingly well -- maybe 5% of our CSS needed RTL-specific overrides.
The ISR Strategy That Actually Worked
ISR (Incremental Static Regeneration) is the hero of this story, but using it well at scale requires understanding how Vercel's infrastructure actually works.
Revalidation Timing
We used different revalidation periods depending on content type:
| Page Type | Revalidate Period | Reasoning |
|---|---|---|
| Product pages | 300s (5 min) | Prices/stock change frequently |
| Category pages | 900s (15 min) | Product listings update less often |
| Marketing/CMS pages | 3600s (1 hour) | Content changes are planned |
| Homepage per locale | 600s (10 min) | Balance of freshness and caching |
On-Demand Revalidation
For critical updates (price changes, stock outages), we set up on-demand revalidation via webhook from our headless CMS:
// pages/api/revalidate.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { secret, slug, locales } = req.body;
if (secret !== process.env.REVALIDATION_SECRET) {
return res.status(401).json({ message: 'Invalid secret' });
}
try {
const targetLocales = locales || ['en']; // Default to English if not specified
const revalidations = targetLocales.map((locale: string) =>
res.revalidate(`/${locale}/products/${slug}`)
);
await Promise.all(revalidations);
return res.json({ revalidated: true, paths: targetLocales.length });
} catch (err) {
return res.status(500).json({ message: 'Error revalidating' });
}
}
One gotcha: when you revalidate a product that exists in 30 locales, you're making 30 revalidation calls. For a bulk update of 100 products, that's 3,000 revalidation requests. We had to add rate limiting and queue these through a serverless function to avoid hitting Vercel's API limits.
The Stale-While-Revalidate Pattern
ISR's beauty is that it serves stale content while regenerating in the background. For this project, that meant users always got a fast response (cached HTML from Vercel's edge), even if the data was up to 5 minutes old. For an e-commerce site, this was an acceptable tradeoff -- the cart and checkout flow always hit live APIs for real-time stock/pricing.
Content Pipeline and Headless CMS Integration
The content lived in a headless CMS (Contentful, in this case, though we've done similar setups with Sanity and Storyblok for other clients -- see our headless CMS development services for more on this).
Contentful's localization model worked well for 30 locales. Each entry has locale-specific field values, and their API supports querying by locale. But there's a performance consideration: fetching a product with all 30 locales' data is significantly larger than fetching one locale.
We always queried for a single locale in getStaticProps:
const product = await contentfulClient.getEntry(productId, {
locale: mapToContentfulLocale(locale), // 'en-US', 'de-DE', etc.
include: 2, // Resolve 2 levels of linked entries
});
This kept API response times under 200ms even for complex product entries with multiple references.
Translation Management
For UI translations (buttons, labels, error messages), we used Crowdin integrated with our Git repo. The workflow:
- Developers add new English strings to
messages/en/*.json - Crowdin syncs and notifies translators
- Translations come back as PRs
- CI validates JSON structure and completeness
- Missing translations fall back to English
The fallback strategy is critical. You never want a production page showing translation keys like product.add_to_cart. Our fallback chain was: requested locale → language family (e.g., pt-BR → pt) → English.
Performance Results and Core Web Vitals
After launch, here's what we measured across all 30 locales:
| Metric | Target | Actual (P75) | Notes |
|---|---|---|---|
| LCP | < 2.5s | 1.8s | ISR cache hit |
| FID | < 100ms | 45ms | Minimal client-side JS |
| CLS | < 0.1 | 0.03 | Font loading strategy helped |
| TTFB | < 800ms | 120ms | Vercel edge, cached pages |
| TTFB (cache miss) | < 2s | 1.4s | ISR generating on first request |
| Build time | < 20min | 11min 40s | Only pre-generating 8K pages |
The TTFB numbers are the star here. 120ms for cached pages means users in Tokyo, São Paulo, and Frankfurt all get fast responses from nearby edge nodes. The 1.4s for cache misses is the ISR generation time -- acceptable because it only happens once per page per revalidation period.
Font Loading for 30 Languages
One performance challenge specific to multilingual sites: fonts. You can't use a single font family for 30 languages. We needed:
- Latin/Cyrillic: Inter (most European languages)
- Arabic: Noto Sans Arabic
- CJK: Noto Sans JP/KR/SC/TC
- Thai: Noto Sans Thai
Using next/font with per-locale font loading prevented unnecessary font downloads. A user visiting the Japanese site only downloads Noto Sans JP, not the Arabic or Thai fonts.
Cost Breakdown on Vercel
Let's talk money, because this is where large-scale ISR gets interesting. Here's our monthly Vercel bill breakdown in 2025:
| Line Item | Monthly Cost | Notes |
|---|---|---|
| Vercel Pro plan | $20/seat × 4 | Base team plan |
| Bandwidth (8TB/mo) | ~$320 | $40/TB after first 1TB |
| Serverless Function executions | ~$180 | ISR regeneration + API routes |
| Edge Middleware executions | ~$45 | Locale detection |
| ISR writes | ~$90 | Cache write operations |
| Total | ~$715/mo |
For a site handling 2M+ pageviews/month across 30 locales, $715 is extremely reasonable. The alternative -- running SSR on dedicated infrastructure -- would have cost $2,000-4,000/month for equivalent performance and reliability.
One thing to watch: ISR cache write costs can spike if you trigger mass revalidation. We had an incident where a CMS bulk publish triggered revalidation for 15,000 pages simultaneously. That single event cost about $40 in extra function executions. We now batch revalidation calls with a 100ms delay between them.
Mistakes We Made and How We Fixed Them
I'd be lying if I said this went smoothly from day one. Here are the biggest mistakes:
Mistake 1: Generating All Locales at Build Time
Our first approach tried to pre-generate every page in every locale. The build ran for 3 hours and 47 minutes. Then it failed because Vercel's build timeout (on Pro) is 45 minutes. Even after moving to a custom build server, the deploy process was miserable.
Fix: Partial pre-generation with fallback: 'blocking'. Build only the pages that matter most, let ISR handle the long tail.
Mistake 2: Not Setting `fallback` Correctly
We initially used fallback: true instead of fallback: 'blocking'. The difference matters: true serves a skeleton/loading state on first request, while blocking waits for the page to generate. With true, we were getting hydration errors because our product components expected data that wasn't there yet during the fallback render.
Fix: Switched to fallback: 'blocking'. The first visitor to an uncached page waits 1-2 seconds, but everyone after that gets the cached version instantly.
Mistake 3: SEO Hreflang Tags Were Wrong
This is an easy one to mess up. Google needs hreflang tags to understand the relationship between localized pages. Our initial implementation was missing the x-default tag and had inconsistencies between the <link> tags and the XML sitemap.
// Correct hreflang implementation
<Head>
{locales.map(loc => (
<link
key={loc}
rel="alternate"
hrefLang={loc}
href={`https://example.com/${loc}${path}`}
/>
))}
<link rel="alternate" hrefLang="x-default" href={`https://example.com/en${path}`} />
</Head>
Mistake 4: Sitemap Generation
With 91K URLs, a single sitemap XML file won't work (Google's limit is 50,000 URLs per sitemap). We needed a sitemap index with multiple child sitemaps, split by locale:
<!-- sitemap-index.xml -->
<sitemapindex>
<sitemap><loc>https://example.com/sitemaps/en.xml</loc></sitemap>
<sitemap><loc>https://example.com/sitemaps/de.xml</loc></sitemap>
<!-- ... 28 more -->
</sitemapindex>
We generated these using next-sitemap with custom configuration, and they're regenerated on each build.
When to Use This Stack (and When Not To)
This architecture -- Next.js + i18n + ISR on Vercel -- is powerful, but it's not the right choice for everything.
Use this when:
- You have 10+ locales with thousands of pages
- Content updates are frequent but not real-time
- Performance and Core Web Vitals matter for SEO
- Your team knows React/Next.js well
Consider alternatives when:
- You have fewer than 5 locales and under 1,000 pages (plain SSG might be simpler)
- Content is truly real-time (stock trading, live scores) -- use SSR or client-side fetching
- You're budget-constrained on hosting -- consider Astro for purely static multilingual sites at a fraction of the cost
- Your team is small and doesn't need React's interactivity -- a static site generator with i18n might be less to maintain
For teams considering a project like this, we've helped several enterprise clients architect and build large-scale Next.js applications. The architecture decisions in the first two weeks determine whether the project succeeds or becomes a maintenance nightmare. If you want to talk through your specific situation, get in touch.
FAQ
How does Next.js i18n routing work with ISR?
Next.js i18n routing adds locale prefixes to URLs (like /fr/products/shoes). When combined with ISR, each locale + page combination is cached independently at Vercel's edge. So /en/products/shoes and /fr/products/shoes are separate cache entries, each with their own revalidation timer. The getStaticProps function receives the locale in its context, and you fetch the appropriate translations and localized content there.
What's the maximum number of pages Next.js ISR can handle on Vercel?
There's no hard technical limit on the number of ISR pages Vercel can serve. We've run 91K+ pages successfully, and I've heard of projects with 500K+ pages. The practical limits are build time (for pre-generated pages), revalidation throughput, and cost. Vercel's edge cache is designed for this scale -- it's essentially a CDN with smart invalidation.
Does ISR affect SEO for multilingual sites?
No, ISR pages are fully rendered HTML when served from cache, which is what search engine crawlers see. The key SEO considerations are proper hreflang tags, a well-structured sitemap index with per-locale sitemaps, and making sure your fallback: 'blocking' setting prevents crawlers from seeing incomplete pages. Google has confirmed that ISR/cached pages are treated the same as traditional static HTML.
How do you handle translation updates without redeploying?
For CMS-managed content (product descriptions, marketing copy), translations update automatically through ISR revalidation -- either on the timer or via on-demand revalidation webhooks. For UI string translations (button labels, form validation messages), those are bundled at build time, so they require a redeploy. We keep these separate intentionally: content changes should never require a deploy, but UI string changes go through code review.
What's the cost difference between ISR and SSR for multilingual sites on Vercel?
SSR executes a serverless function on every single request. At 2M pageviews/month, that's 2M function invocations at roughly $0.40 per million after the free tier -- about $800/month in function costs alone, plus significantly higher bandwidth since there's less caching. ISR serves most traffic from the edge cache (zero function cost) and only invokes functions for cache misses and revalidation. Our ISR setup cost about $715/month total, while equivalent SSR would have been $2,500-3,500/month.
How do you handle different date, number, and currency formats across 30 locales?
We use the browser's built-in Intl API through next-intl's formatting utilities. This handles date formatting (Intl.DateTimeFormat), number formatting (Intl.NumberFormat), and currency display correctly for each locale. The ICU message format lets you embed these formatters directly in translation strings: "price": "From {amount, number, ::currency/EUR}". This works server-side during ISR generation and client-side for dynamic values.
Should I use App Router or Pages Router for large-scale i18n?
As of Next.js 15 (mid-2025), the App Router's i18n story has matured significantly, and next-intl v4 has excellent App Router support. For new projects, I'd recommend App Router. It offers better streaming, React Server Components (which reduce client-side JavaScript), and more granular caching controls. Our project used Pages Router because it was started in 2024 when App Router i18n was less stable, but a greenfield project today should go App Router.
What happens if ISR revalidation fails? Do users see an error page?
No, and this is one of ISR's best features. If revalidation fails (maybe the CMS API is down, or there's a code error in getStaticProps), Vercel continues serving the last successfully generated version of the page. Users never see an error -- they just see slightly stale content. The failed revalidation is logged, and the next revalidation attempt will try again. This makes ISR incredibly resilient compared to SSR, where an API outage immediately becomes a user-facing outage.