Next.js i18n for Indian Languages: Hindi, Malayalam, Tamil Guide Next.js i18n setup guide for Hindi Malayalam and Tamil

India has roughly 730 million internet users who primarily browse in a language other than English — Hindi, Bengali, Telugu, Marathi, Tamil, Kannada, Malayalam, and dozens more. Despite this, most Next.js projects targeting Indian markets are built English-only, leaving a massive segment of users with a degraded or inaccessible experience. The technical side of adding Indian language support to a Next.js app is not particularly difficult, but there are enough Indian-specific gotchas — Malayalam rendering on Android, lakh number formatting, Urdu RTL layout — that a general i18n tutorial won't cover them. This guide goes deep on the specifics.

Why Multilingual Matters Specifically for Indian Next.js Projects

Language localisation in Indian consumer products is no longer a differentiator — it is becoming a baseline expectation in certain sectors. Government e-services must support all 22 scheduled languages. Agriculture platforms that serve rural users in UP, Bihar, and Maharashtra need Hindi and Bhojpuri. Healthcare apps targeting patients in Tamil Nadu need Tamil, because medical information in a second language carries a measurably higher error risk. A fintech app aiming at the Tier-2 and Tier-3 market in Kerala will see significantly higher conversion when the onboarding flow is in Malayalam.

From a search perspective, language-specific URLs (/hi/, /ml/) allow Google to index your content separately for each language, meaning your Hindi pages can rank for Hindi-language search queries independently from your English pages. Indian-language search volume on Google has been growing faster than English search in India for several years running, making this an SEO opportunity that remains underexploited by most development teams.

Next.js Built-in i18n Routing: next.config.js Setup

Next.js has had built-in i18n routing support since version 10. In the Pages Router, you configure it in next.config.js. In the App Router (Next.js 13+), the built-in i18n config is no longer used — instead, you handle routing through your folder structure directly, which is why most App Router projects now reach for next-intl or next-i18next.

For Pages Router projects still in production, the next.config.js i18n block looks like this:

// next.config.js
module.exports = {
  i18n: {
    locales: ['en', 'hi', 'ml', 'ta', 'ur'],
    defaultLocale: 'en',
    localeDetection: true,
  },
};

With this configuration, Next.js automatically generates locale-prefixed routes: /hi/about, /ml/about, /ta/about. The default locale (en) is served at the root path without a prefix — /about — while all other locales get a prefix. You can override this behaviour using domains if you want separate domains per locale, but for most Indian projects, the subdirectory approach is simpler and better from an SEO standpoint.

localeDetection: true tells Next.js to read the Accept-Language HTTP header from the browser and redirect users to their preferred locale automatically on first visit. This is useful for Indian users whose browsers are set to Hindi or Tamil, but it can cause confusion if implemented without a clear language switcher UI that lets users override the detection.

next-intl vs next-i18next vs Custom JSON: Comparing Approaches

Once routing is in place, you need a way to manage translation strings. The three main approaches for Next.js Indian projects each have distinct trade-offs.

next-i18next is the older, more established library built on top of i18next. It is battle-tested, has a large ecosystem of plugins, and works well with the Pages Router. Its translation files are JSON, organised by namespace (a namespace might be "common", "nav", "checkout"). The downside for new projects is that it was designed primarily for Pages Router and requires some workarounds in App Router. If you have an existing Pages Router project with i18next translations, next-i18next is the natural choice.

next-intl is the current recommended library for App Router projects. It is built with React Server Components in mind, supports both server and client components natively, and handles the ICU message format — which is important for Indian languages because plural forms and gender agreements work very differently from English. Hindi has two grammatical genders. Malayalam has a complex system of honourifics. ICU format lets you express these variations properly in translation strings rather than hardcoding conditional logic in your components.

Custom JSON files with useContext works fine for small projects with two or three languages and a limited number of strings. You load a JSON file based on the current locale and access keys through a custom hook. The maintenance burden grows quickly as the project scales — you lose type safety, pluralisation support, and interpolation — but for a small landing page or a prototype, it is the fastest path to multilingual output.

For any production Indian project with Hindi and at least one other language, next-intl with the App Router is the recommended approach in 2026. Here is a minimal working setup:

// messages/en.json
{
  "HomePage": {
    "title": "Welcome to our store",
    "itemCount": "{count, plural, one {# item} other {# items}} in your cart"
  }
}

// messages/hi.json
{
  "HomePage": {
    "title": "हमारी दुकान में आपका स्वागत है",
    "itemCount": "आपकी कार्ट में {count} आइटम"
  }
}

// app/[locale]/layout.tsx
import {NextIntlClientProvider} from 'next-intl';
import {getMessages} from 'next-intl/server';

export default async function LocaleLayout({
  children,
  params: {locale}
}: {
  children: React.ReactNode;
  params: {locale: string};
}) {
  const messages = await getMessages();
  return (
    <html lang={locale} dir={locale === 'ur' ? 'rtl' : 'ltr'}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Loading Indian Language Fonts Without Layout Shift

Indian script fonts are substantially larger than Latin fonts. A full Noto Sans Devanagari (Hindi) font file is several hundred kilobytes. Loading it naively will cause noticeable layout shift as the page renders first in a fallback font and then reflows when the Indian script font loads. Cumulative Layout Shift (CLS) directly affects your Core Web Vitals score, so this is both a user experience and an SEO problem.

The right approach is to load Indian language fonts only when the current locale requires them, and to use font-display: swap with a carefully chosen fallback font that has similar metrics.

For Hindi (Devanagari script), use Noto Sans Devanagari. For Malayalam, use Noto Sans Malayalam. For Tamil, use Noto Sans Tamil. These are all available on Google Fonts and are specifically designed to match each other's line metrics, which minimises layout shift when swapping from the fallback.

// app/[locale]/layout.tsx — conditional font loading

import {Noto_Sans_Devanagari, Noto_Sans_Malayalam, Noto_Sans_Tamil}
  from 'next/font/google';

const devanagari = Noto_Sans_Devanagari({
  subsets: ['devanagari'],
  weight: ['400', '500', '600'],
  display: 'swap',
  variable: '--font-devanagari',
});

const malayalam = Noto_Sans_Malayalam({
  subsets: ['malayalam'],
  weight: ['400', '500', '600'],
  display: 'swap',
  variable: '--font-malayalam',
});

const tamil = Noto_Sans_Tamil({
  subsets: ['tamil'],
  weight: ['400', '500', '600'],
  display: 'swap',
  variable: '--font-tamil',
});

// Apply the relevant font variable based on locale
const localeFontMap: Record<string, string> = {
  hi: devanagari.variable,
  ml: malayalam.variable,
  ta: tamil.variable,
};

In your CSS, define the font stacks so the correct script font is applied when its CSS variable is present:

:root {
  --font-body: 'Inter', system-ui, sans-serif;
}

[lang="hi"] body {
  font-family: var(--font-devanagari), var(--font-body);
}

[lang="ml"] body {
  font-family: var(--font-malayalam), var(--font-body);
}

[lang="ta"] body {
  font-family: var(--font-tamil), var(--font-body);
}

Using next/font/google instead of a raw <link> tag in your <head> gives you automatic size adjustment and CLS mitigation that Next.js handles internally. The font is downloaded at build time and served from your own domain, eliminating the cross-origin DNS lookup latency of fetching from Google Fonts on every request.

RTL Layout for Urdu

Urdu is written right-to-left in the Nastaliq or Naskh script. Setting dir="rtl" on the <html> element is the first step, but a full RTL implementation requires more thought.

CSS logical properties (margin-inline-start, padding-inline-end, border-inline-start) are the modern way to write layout styles that adapt automatically when the direction changes, without needing separate RTL stylesheets. If your existing CSS uses margin-left and margin-right extensively, those will not flip automatically in RTL — you will need to audit and replace them.

The font to use for Urdu is Noto Nastaliq Urdu for a traditional Nastaliq rendering, or Noto Sans Arabic for a simpler, more readable Naskh style that works better at small screen sizes. Nastaliq is preferred by Urdu readers for its authenticity, but it renders poorly below 18px on mobile screens due to its vertical stacking of characters.

In the App Router layout code shown above, the dir attribute is already handled conditionally: dir={locale === 'ur' ? 'rtl' : 'ltr'}. Test your layout at both dir values — icons with directional meaning (back arrows, forward arrows, chevrons) need to be mirrored in RTL, and flex/grid layouts that use row direction will visually reverse correctly when logical properties are used consistently.

URL Structure and SEO Implications

The choice of URL structure for multilingual Next.js sites has concrete SEO consequences. The three common patterns are:

  • Subdirectory: example.com/hi/, example.com/ml/ — Next.js built-in default, best for most Indian projects
  • Subdomain: hi.example.com, ml.example.com — separate Google crawl budgets, higher infrastructure overhead
  • Query parameter: example.com?lang=hi — Google officially supports this but it is the weakest signal for language targeting and not recommended for new projects

For Indian projects, subdirectory is strongly preferred. It concentrates domain authority, requires no additional DNS or server configuration, and is the path of least resistance with Next.js's built-in i18n router. Add hreflang tags to every page to signal to Google which locale each URL serves:

// app/[locale]/layout.tsx — generating hreflang tags
export async function generateMetadata({params: {locale}}: Props) {
  return {
    alternates: {
      languages: {
        'en': 'https://example.com/en',
        'hi': 'https://example.com/hi',
        'ml': 'https://example.com/ml',
        'ta': 'https://example.com/ta',
        'x-default': 'https://example.com',
      },
    },
  };
}

Locale Detection for Indian Users

Automatic locale detection based on the Accept-Language HTTP header is the starting point, but it has real limitations for Indian users. Many Indian users have English set as their phone language (particularly in Kerala, Tamil Nadu, and urban Maharashtra) even though they are highly fluent in and prefer their regional language for certain types of content. A user with en-IN as their Accept-Language might still want a Malayalam interface for a local government service.

The practical approach is layered detection with user override:

  1. Check for an explicit locale stored in a cookie (set when the user switches language manually)
  2. Fall back to the Accept-Language header if no cookie exists
  3. Fall back to the default locale (en) if neither produces a match from your supported locale list

Always provide a language switcher that is visible and accessible without scrolling — typically in the navigation bar. Label the switcher using the language's own name, not an English translation: "हिन्दी" not "Hindi", "മലയാളം" not "Malayalam". A user who cannot read English should still be able to find and select their preferred language.

In Next.js middleware, you can implement the layered detection before the page renders:

// middleware.ts
import {NextRequest, NextResponse} from 'next/server';

const supportedLocales = ['en', 'hi', 'ml', 'ta', 'ur'];
const defaultLocale = 'en';

export function middleware(request: NextRequest) {
  // 1. Check cookie
  const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
  if (cookieLocale && supportedLocales.includes(cookieLocale)) {
    return NextResponse.rewrite(
      new URL(`/${cookieLocale}${request.nextUrl.pathname}`, request.url)
    );
  }

  // 2. Check Accept-Language header
  const acceptLang = request.headers.get('Accept-Language') ?? '';
  const preferred = acceptLang.split(',')[0].split('-')[0];
  const detectedLocale = supportedLocales.includes(preferred)
    ? preferred
    : defaultLocale;

  return NextResponse.rewrite(
    new URL(`/${detectedLocale}${request.nextUrl.pathname}`, request.url)
  );
}

Common Pitfalls with Indian Languages in Next.js

Malayalam rendering on Android Chrome: See the FAQ section below for the full explanation, but the short version is that older Android devices do not render Chillu characters from the system font. Always load Noto Sans Malayalam as a web font rather than relying on the system font, and test on a real Android device at Chrome 90 or older rather than only in DevTools.

Indian number formatting: JavaScript's default Number.toLocaleString() without an explicit locale argument uses the browser's locale, which on many Indian devices defaults to en-US rather than en-IN. This means ₹15,00,000 (fifteen lakh) renders as ₹1,500,000 — the US grouping. Always pass 'en-IN' or 'hi-IN' explicitly: new Intl.NumberFormat('en-IN', {style: 'currency', currency: 'INR'}).format(1500000). In next-intl, configure this once in your i18n formats object and it applies globally.

Date format differences: India uses the DD/MM/YYYY date format in most official contexts, opposite to the US MM/DD/YYYY. When rendering dates in translated content, use Intl.DateTimeFormat with the appropriate locale rather than manually constructing date strings. Hindi dates may also need the Indian calendar system (Vikram Samvat) in certain government or traditional contexts — check your use case requirements early.

Mixing scripts in the same string: Product names, proper nouns, and brand names are often left untranslated (in Latin script) within otherwise Hindi or Malayalam sentences. Ensure your CSS handles the mixed-script line height and font metric differences gracefully — a Hindi paragraph containing an English product name in the middle can break vertical rhythm if the Latin and Devanagari fonts have significantly different line heights.

Translation file management at scale: Once you have five or more languages and hundreds of translation keys, managing JSON files manually becomes error-prone. Consider a translation management system (TMS) like Crowdin or Lokalise, which integrates with Next.js projects and tracks which keys are missing translations for which locales. Missing translation keys in next-intl fail silently in production — they fall back to the key string itself — so a TMS that flags missing keys before deployment is valuable.

Frequently Asked Questions

Why does Malayalam text render as boxes or tofu on some Android devices despite correct font loading?

This is a known issue on older Android versions (below Android 10) and on some mid-range devices where the system font does not fully support Malayalam Unicode, particularly the newer Chillu characters introduced in Unicode 5.1. The fix is to ensure your Next.js app loads Noto Sans Malayalam as a web font with font-display: swap, and that your CSS applies the Malayalam font explicitly to any element containing Malayalam text — not just to the body. Relying on system font fallback alone will produce tofu on many Indian Android devices. Additionally, avoid using font subsetting that strips Chillu characters; request the full character range when loading from Google Fonts.

Should I use /hi/ URL paths or a separate subdomain like hi.example.com for Hindi content in Next.js?

For most Indian projects, subdirectory paths (/hi/, /ml/, /ta/) are preferable over subdomains from both an SEO and implementation standpoint. With Next.js built-in i18n routing, subdirectory paths are generated automatically and require no additional DNS configuration. From Google's perspective, subdirectory paths inherit the domain authority of the root domain more reliably than subdomains, which Google treats as separate sites by default. Subdomains make sense only if you have distinct editorial teams per language and want fully independent crawl budgets — an unusual requirement for most Indian projects.

How do I format numbers in lakhs and crores correctly across Hindi and English locales in Next.js?

JavaScript's Intl.NumberFormat API handles Indian number formatting natively when you pass the 'en-IN' or 'hi-IN' locale. For example, new Intl.NumberFormat('en-IN').format(1500000) returns '15,00,000' — the Indian grouping of lakh — rather than '1,500,000'. In a next-intl setup, you define your number format once in your i18n configuration using the formats option, then reference it in translation messages using the number ICU format type. The key pitfall is defaulting to 'en-US' locale in your formatters — always explicitly pass the user's active locale to Intl.NumberFormat, or Indian users will see the wrong grouping separator.