SStackforge
← Back to all posts

How I Built a Bilingual Next.js App with Subdomain-Based i18n (No Library Needed)

nextjsi18nsupabasebuildinpublic

I recently added full Chinese + English support to a Next.js 16 App Router project — a vocal training app called Daily Vocal Plan. The setup uses subdomains (cn.example.com / en.example.com) instead of path prefixes (/zh/... / /en/...), and I deliberately avoided heavy i18n libraries like next-intl or i18next.

The same Next.js app rendered in Chinese and English across two subdomains

This post is a full write-up of how it works, why I chose this approach, and the gotchas I hit along the way.

Why Subdomains Instead of Path Prefixes?

Both approaches are valid. I chose subdomains because:

  1. Cleaner SEO — Each language gets its own domain, its own sitemap, its own Search Console property. cn.example.com and en.example.com can be indexed independently without hreflang complexity.
  2. No URL collisions — My internal routing doesn't have to worry about whether /practice means /zh/practice or /en/practice.
  3. Easier CDN — Vercel handles per-domain caching cleanly.
  4. Future expansion — Adding Japanese is just ja.example.com, no refactor of the route tree.

The trade-off: you can't switch language without changing the domain. But for a content-driven app like this, that's acceptable.

The Architecture

Four files make the whole thing work:

lib/
  i18n.ts           → messages + locale detection
  i18n-server.ts    → server-side helper
  i18n-client.ts    → client-side hook
proxy.ts            → injects x-locale header
app/
  layout.tsx        → reads locale, renders correct metadata

No external i18n library. No JSON bundles. No useTranslation provider.

1. The Message File

Everything lives in one TypeScript object:

// lib/i18n.ts
export const locales = ['zh', 'en'] as const
export type Locale = typeof locales[number]

export function getLocaleFromHost(host: string): Locale {
    if (process.env.NODE_ENV === 'development') {
        const forced = process.env.NEXT_PUBLIC_FORCE_LOCALE
        if (forced === 'zh' || forced === 'en') return forced
    }

    if (host.startsWith('cn.')) return 'zh'
    if (host.startsWith('en.')) return 'en'
    return 'zh'
}

export const messages = {
    zh: {
        layout: {
            title: '',
            description: '',
        },
        auth: {
            welcomeBack: '',
            sendCode: '',
            // ...
        },
        // ...
    },
    en: {
        layout: {
            title: '10 minutes per day, daily voice training with AI...',
            description: 'By practicing the daily voice opening AI training program...',
        },
        auth: {
            welcomeBack: 'Welcome Back',
            sendCode: 'Send Verification Code',
            // ...
        },
        // ...
    },
} as const

export type Messages = typeof messages.zh

export function format(
    template: string,
    vars: Record<string, string | number>
): string {
    return template.replace(/\{(\w+)\}/g, (_, key) => String(vars[key] ?? ''))
}

Why as const? It gives you full TypeScript inference. If you typo t.auth.welcomeBack as t.auth.welcomBack, TS will error. No runtime surprises.

Why not JSON? JSON doesn't give you autocomplete, and you lose the ability to define functions as message values (which I use for {count} interpolation).

2. The Proxy (formerly Middleware)

Next.js 16 renamed middleware.ts to proxy.ts. If you're on 16+, use proxy. If you're on 15 or earlier, use middleware.

// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
import { getLocaleFromHost } from '@/lib/i18n'

export async function proxy(request: NextRequest) {
    const host = request.headers.get('host') || ''
    const locale = getLocaleFromHost(host)

    // 1. Let Supabase refresh its session cookies
    const supabaseResponse = await updateSession(request)

    // 2. Inject x-locale into the downstream request
    const requestHeaders = new Headers(request.headers)
    requestHeaders.set('x-locale', locale)

    const response = NextResponse.next({
        request: { headers: requestHeaders },
    })

    // 3. Copy Supabase's cookies over
    supabaseResponse.cookies.getAll().forEach((cookie) => {
        response.cookies.set(cookie)
    })

    return response
}

export const config = {
    matcher: [
        '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
    ],
}

Key insight: NextResponse.next({ request: { headers } }) is what propagates the header to Server Components. Without it, headers() in the page will not see x-locale.

3. Server-Side Helper

For Server Components and generateMetadata:

// lib/i18n-server.ts
import { headers } from 'next/headers'
import { messages, getLocaleFromHost, type Locale } from './i18n'

export async function getServerLocale(): Promise<Locale> {
    const headersList = await headers()
    const host = headersList.get('host') || ''
    return getLocaleFromHost(host)
}

export async function getServerMessages() {
    const locale = await getServerLocale()
    return { locale, t: messages[locale] }
}

Note: I read host directly instead of x-locale — it's more robust if proxy fails for some reason.

Usage in a Server Component:

export default async function Page() {
    const { locale, t } = await getServerMessages()
    return <h1>{t.home.heroTitle}</h1>
}

4. Client-Side Hook

For Client Components:

// lib/i18n-client.ts
'use client'

import { useEffect, useState } from 'react'
import { messages, getLocaleFromHost, type Locale } from './i18n'

export function useLocale(): Locale {
    const [locale, setLocale] = useState<Locale>('zh')

    useEffect(() => {
        setLocale(getLocaleFromHost(window.location.host))
    }, [])

    return locale
}

export function useMessages() {
    const locale = useLocale()
    return { locale, t: messages[locale] }
}

Client Components can't use headers(), so we read window.location.host in useEffect.

5. Dynamic Metadata

generateMetadata in app/layout.tsx produces per-language SEO tags:

export async function generateMetadata(): Promise<Metadata> {
    const { locale, t } = await getServerMessages()

    const baseUrl = locale === 'zh'
        ? 'https://cn.example.com'
        : 'https://en.example.com'

    return {
        title: t.layout.title,
        description: t.layout.description,
        keywords: t.layout.keywords,
        alternates: {
            canonical: baseUrl,
            languages: {
                'zh-CN': 'https://cn.example.com',
                'en-US': 'https://en.example.com',
            },
        },
        openGraph: {
            title: t.layout.title,
            description: t.layout.description,
            url: baseUrl,
            locale: locale === 'zh' ? 'zh_CN' : 'en_US',
            type: 'website',
        },
    }
}

The alternates.languages object automatically generates hreflang tags in the <head>, which is the modern replacement for <meta http-equiv="content-language">.

The Gotchas

1. Supabase Email Templates Don't Follow Locale Automatically

Supabase's auth emails (magic links, OTP codes) are rendered server-side by Supabase, not by your app. They don't know whether the user is on cn. or en..

The fix: pass the locale as user metadata:

await supabase.auth.signInWithOtp({
    email,
    options: {
        shouldCreateUser: true,
        data: { locale: 'en' },
    },
})

Then in Supabase Dashboard → Authentication → Email Templates → Magic Link:

{{ if eq .Data.locale "zh" }}
  <h2>您的登录验证码</h2>
  <!-- ... -->
{{ else }}
  <h2>Your Login Verification Code</h2>
  <!-- ... -->
{{ end }}

Subtle bug: For already-registered users, Supabase ignores the data field. It only writes it on user creation. So existing users whose metadata lacks locale will always get the default branch.

The fix: after successful login, update the user:

await new Promise(resolve => setTimeout(resolve, 500))  // wait for session
await supabase.auth.updateUser({ data: { locale } })

Then, on their next OTP request, Supabase will read the new metadata and send the correct language.

2. headers() Returns a Promise in Next.js 15+

In Next.js 15 and later, headers() from next/headers is async:

// ❌ Old
const headersList = headers()

// ✅ New
const headersList = await headers()

If you're migrating from 14, this is a common break.

3. as const Will Block Dynamic Keys

With as const on the messages object, you can't do:

const key = 'welcomeBack' as string
t.auth[key]  // ❌ Type error

You need explicit casts:

const key = 'welcomeBack'
t.auth[key as keyof typeof t.auth]  // ✅

For dynamic keys coming from external data (like a DB row), I cast the whole t object:

const levelKey = 'levelBasic'   // comes from DB
const label = (t.newPractice as any)[levelKey]

It's ugly, but it only appears in a couple of places.

What I'd Do Differently

  1. Extract a <T> helper for dynamic keys instead of spreading as any.
  2. Move i18n.ts into a package if more apps need it. Right now it's a local file.
  3. Add a ja locale just to test that expansion is zero-effort. (It is — just add the object and a host check.)

Final Thoughts

You don't need next-intl or i18next for a two-language app. A single messages object, a proxy.ts, and two small helpers are enough.

The approach scales cleanly to 3-5 languages. Beyond that, a proper library starts paying for itself in tooling (translation management, plural rules, date formatting).

For now, this is ~200 lines of code total, with full TypeScript safety and zero runtime dependencies.


If you're building something similar, or if you've found a simpler pattern, I'd love to hear about it.