
next-intl for i18n: Why I Added English to My Korean Service
Adding English to a Korean-only blog with next-intl. Lessons from implementing i18n in Next.js App Router, including routing, message files, and Server Components.

Adding English to a Korean-only blog with next-intl. Lessons from implementing i18n in Next.js App Router, including routing, message files, and Server Components.
My admin account was hijacked because of a single comment on the board. I dive deep into the 3 types of XSS (Stored, Reflected, DOM) and concrete defense strategies in React/Next.js environments, including HTML Escaping, CSP, and Cookie Security.

Do you know what the Circle (○) and Lambda (λ) symbols mean in Next.js build logs? Ensure you aren't accidentally making every page dynamic.

I built a service and nobody came. How I implemented technical SEO in Next.js to drive organic traffic without a marketing budget.

I tried to save money by deploying to AWS S3 instead of Vercel, but ended up with a broken site. I share the three nightmares of Static Export (Image Optimization, API Routes, Dynamic Routing) and how to fix them.

I ran a Korean-only blog for a while. Then the messages started coming in.
"Your posts are really useful, but I can't read Korean. Is there an English version?"
Once — easy to ignore. Three times — hard to dismiss. My writing covers technical topics, and technical content travels across language barriers better than most. An international reader could get real value from it if only the language weren't in the way.
There was also the portfolio angle. A Korean-only portfolio is a Korean-only portfolio. An internationalized one is something else.
The question wasn't whether to add English. The question was how to do it without making a mess.
Three realistic options existed: next-i18next, next-intl, or rolling my own.
next-i18next is the old standard from the Pages Router era. It works with App Router, but it was designed for a different architecture. Using it with Server Components involves awkward workarounds that shouldn't exist in a properly integrated setup.
Rolling my own — I tried this briefly. Pass locale via context, load JSON files, access keys with dot notation. Simple at first. Then plural forms, error handling, TypeScript types, and missing key handling start piling up. You end up writing a library in order to avoid using a library.
next-intl won for two concrete reasons.
First, it has first-class App Router and Server Components support. getTranslations() for server components, useTranslations() for client components — the API maps directly to Next.js's architecture.
Second, TypeScript autocomplete works out of the box. A typo in a translation key is a compile-time error, not a runtime mystery. That's the kind of safety net that actually gets used every day.
The central idea in next-intl is straightforward: every page lives inside a [locale] folder. URLs change shape:
Before: /blog/some-post
After: /ko/blog/some-post
/en/blog/some-post
Move everything under src/app/[locale]/. Then configure the middleware:
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['ko', 'en'],
defaultLocale: 'ko',
localePrefix: 'always',
});
The middleware handles locale detection automatically. A visitor landing on /blog/some-post gets redirected to /ko/blog/some-post or /en/blog/some-post based on their Accept-Language header. No manual detection code needed.
I chose localePrefix: 'always' so both locales always show the prefix in the URL. SEO consistency matters more to me than the slightly cleaner URL you'd get with 'as-needed'.
Translation text lives in messages/ko.json and messages/en.json. Think of them as phrase books.
A traveler in Japan who doesn't speak the language looks up "これをください" in their phrase book. An app that needs to say "Recent Posts" looks up Home.recentPosts in its phrase book — and finds either "최근 포스트" or "Recent Posts" depending on which book it's reading from.
The critical decision is namespace structure. Flat keys become unmanageable past a hundred entries. Page-based namespacing keeps things searchable:
{
"Home": {
"recentPosts": "Recent Posts",
"viewAll": "View All"
},
"Blog": {
"title": "Blog",
"allPosts": "All Posts",
"noResults": "No results found.",
"categories": {
"all": "All",
"frontend": "Frontend",
"backend": "Backend"
}
},
"Navigation": {
"home": "Home",
"blog": "Blog",
"projects": "Projects",
"about": "About"
}
}
Blog.categories.frontend tells you exactly what it is and where it lives. When you have 300 keys, you need that signal.
The rule is simple once you internalize it:
| Component type | Function | Why |
|---|---|---|
| Server Component | getTranslations() (async) | React hooks don't work in RSC |
| Client Component | useTranslations() (hook) | Browser environment, hooks work |
Server component usage:
// src/app/[locale]/blog/page.tsx
import { getTranslations } from 'next-intl/server';
export default async function BlogPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Blog' });
return (
<main>
<h1>{t('title')}</h1>
</main>
);
}
Client component usage:
// src/components/layout/Header.tsx
'use client';
import { useTranslations } from 'next-intl';
export function Header() {
const t = useTranslations('Navigation');
return (
<nav>
<a href="/">{t('home')}</a>
<a href="/blog">{t('blog')}</a>
</nav>
);
}
I hit the "useTranslations is only available in Client Components" error early on. Once you understand why — React hooks can't run in server-side code — it stops being confusing.
The mental model that clicked for me: i18n is building two entrances to the same building.
The building itself — content, features, logic — is one thing. But there are two entrances. Walk through the Korean entrance (/ko/) and all the signs are in Korean. Walk through the English entrance (/en/) and the signs are in English. The building's interior structure is identical. Only the signage changes.
This shapes how you split the work into three layers:
[locale] folder hierarchyko.json, en.json)_ko/_en fieldsMy early mistake was trying to put blog post titles in the message files. Fifty posts, two languages each — that's a hundred entries just for titles, stuffed into a file meant for UI text. Content belongs in MDX frontmatter. Message files are for UI text only.
next-intl includes useFormatter() for locale-aware date and number formatting. I initially wrote my own formatting function. That was unnecessary.
'use client';
import { useFormatter } from 'next-intl';
function PostDate({ dateString }: { dateString: string }) {
const format = useFormatter();
const date = new Date(dateString);
return (
<time dateTime={dateString}>
{format.dateTime(date, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
);
}
Korean locale produces "2026년 3월 3일". English locale produces "March 3, 2026". No conditional logic, no manual locale passing. useFormatter() reads the current locale automatically and delegates to Intl.DateTimeFormat.
Each locale needs its own metadata, and Google needs to know the relationship between them. The hreflang attribute in <link> tags tells search engines that /ko/blog/slug and /en/blog/slug are the same content in different languages.
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> {
const { locale, slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return {};
const title = localized(post, 'title', locale);
const description = localized(post, 'description', locale);
return {
title,
description,
alternates: {
canonical: `/${locale}/blog/${slug}`,
languages: {
ko: `/ko/blog/${slug}`,
en: `/en/blog/${slug}`,
},
},
};
}
Without alternates.languages, Google might treat the two URLs as duplicate content and rank them against each other. With it, Google understands they're variants and handles them correctly in search results.
next-intl fits App Router natively. getTranslations() for server, useTranslations() for client — the API respects Next.js's architecture instead of fighting it.
Message files are for UI text, not content. Blog post titles and body text belong in MDX frontmatter with _ko/_en fields. Mixing them into message files creates an unmaintainable mess.
Namespace by page. Blog.title, Navigation.home, Home.recentPosts — flat keys work until they don't. Starting namespaced saves pain later.
useFormatter() handles dates and numbers. No custom formatting logic needed. It reads the current locale and delegates to platform APIs automatically.
Don't skip hreflang. generateMetadata()'s alternates.languages field is two lines of code. Skipping it risks duplicate content penalties in search rankings.
I started this to serve English-speaking readers. The side effect was cleaner code: UI text separated from content, pages with explicit text dependencies, a codebase that's easier to audit. Sometimes the infrastructure work turns out to improve more than it was supposed to.