nextjs-cjk-i18n-typography
Next.js CJK i18n Typography Patterns
Problem
When adding CJK (Chinese/Japanese/Korean) language support to a Next.js app with
next-intl, several non-obvious typography and UX issues arise that don't exist
with Latin-only locales. CJK glyphs are visually larger, denser, and have different
spacing characteristics than Latin characters, requiring locale-aware adjustments
that go beyond simple translation.
Context / Trigger Conditions
- Next.js app using
next-intlwith CJK locales (zh-CN, zh-TW, ja, ko) - Custom CJK fonts loaded via CSS
:lang(zh)selectors or CSS custom properties - Headings or large text that looks oversized in CJK compared to English
- Latin text (signatures, brand names) unexpectedly rendered in CJK font
- Locale switcher redirects back to old locale after switching
- Short CJK headings wrapping to multiple lines unnecessarily
- Route-based visibility logic (hiding elements on certain paths) breaking on locale-prefixed URLs
Solution
Pattern 1: Conditional Font Sizing for CJK
CJK glyphs render ~20-30% visually larger than Latin characters at the same
font-size. Reduce by one Tailwind step at each breakpoint:
import { useLocale } from 'next-intl';
const locale = useLocale();
const isCJK = locale.startsWith('zh') || locale.startsWith('ja') || locale.startsWith('ko');
// h1 example — reduce one step for CJK
<h1 className={`
${isCJK
? 'text-4xl sm:text-5xl lg:text-6xl'
: 'text-5xl sm:text-6xl lg:text-7xl xl:text-[5.5rem]'}
font-medium tracking-tighter
`}>
{t('headline')}
</h1>
Why: CJK fonts (like LXGW WenKai, Noto Sans CJK) have taller x-heights and
wider character bodies than Latin display fonts (like Instrument Serif). Combined
with CJK-specific line-height overrides (e.g., :lang(zh) h1 { line-height: 1.4 }),
headlines can appear dramatically oversized.
Pattern 2: lang Attribute Escape Hatch for Latin Text
When CSS uses :lang(zh) to override fonts, any child element inherits the
language context. Use lang="en" on elements that must always use Latin fonts:
// Signature that should always use Caveat (handwriting font)
<div
lang="en"
className="text-3xl font-handwriting"
style={{ fontFamily: 'var(--font-handwriting)' }}
>
{t('signature')} {/* "Joe" — always Latin text */}
</div>
Why: The lang attribute is inherited in the DOM. If a parent has lang="zh-CN",
then :lang(zh) CSS selectors match all descendants. Adding lang="en" to a
specific element creates a language boundary, preventing CJK font overrides from
applying to Latin text like signatures, brand names, or code snippets.
Pattern 3: whitespace-nowrap vs text-balance for CJK
The CSS text-balance property can force CJK text to wrap unnecessarily because
CJK characters are individually breakable (no word boundaries):
<h2 className={`
${isCJK ? 'whitespace-nowrap' : 'text-balance'}
${isCJK ? 'text-2xl sm:text-3xl' : 'text-3xl sm:text-4xl'}
`}>
{t('sectionTitle')}
</h2>
When to use whitespace-nowrap: Short CJK headings (< 15 characters) that
should always display on one line. Combine with reduced font sizes to ensure they
fit within the viewport.
When NOT to use: Long CJK paragraphs or descriptions where wrapping is expected.
Pattern 4: window.location.href for Locale Switching
When using cookie-based locale persistence (NEXT_LOCALE cookie) with next-intl,
router.push() can cause a redirect loop:
// BAD: Cookie may be stale when RSC fetch is constructed
const router = useRouter();
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`;
router.push(newPath); // Middleware reads OLD cookie value → redirects back
// GOOD: Full page reload guarantees fresh cookies
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`;
window.location.href = newPath; // New request with updated cookie
Why: document.cookie updates the browser's cookie jar synchronously, but
router.push() may construct the RSC (React Server Component) fetch request
before the cookie propagates to the request headers. The middleware then reads
the stale cookie and redirects back to the old locale. A full page reload
(window.location.href) guarantees the browser sends fresh cookies with the
new request.
Trade-off: Full reload is slower than client-side navigation, but locale switches are infrequent enough (user action, not automatic) that this is acceptable.
Pattern 5: Locale-Aware Path Matching
When hiding elements on specific paths (e.g., dev badges on landing pages), strip locale prefixes before matching:
const locales = ['zh-CN', 'zh-TW', 'zh-HK', 'ja', 'ko']; // from i18n config
const localePattern = new RegExp(`^/(${locales.join('|')})`);
const isLandingOrAbout = (pathname: string) => {
const stripped = pathname.replace(localePattern, '');
return ['', '/', '/about', '/releases'].includes(stripped || '/');
};
// Works for /about, /zh-CN/about, /zh-TW/about, etc.
if (isLandingOrAbout(pathname)) return null;
Pattern 6: Conditional Mobile Spacing for CJK
CJK text is typically shorter in character count but each character is wider, creating different visual density than Latin text:
// Tagline under brand name — CJK needs less gap, slightly larger font
<span className={`
${isCJK ? 'text-[9px] mt-0.5' : 'text-[8px] mt-1'}
text-gray-400 leading-none
`}>
{t('tagline')}
</span>
Verification
- Font sizing: Compare CJK and English pages side-by-side — headlines should have similar visual weight despite different character sizes
- Lang attribute: Inspect the element in DevTools → Computed styles should show the Latin font, not the CJK font
- Locale switching: Switch from
/zh-CN/aboutto English → URL should be/aboutwith English content (not redirected back) - Text wrapping: Short CJK headings should display on one line on desktop
Example
Complete isCJK pattern used across a component:
'use client';
import { useTranslations, useLocale } from 'next-intl';
export function HeroSection() {
const t = useTranslations('landing.hero');
const locale = useLocale();
const isCJK = locale.startsWith('zh');
return (
<section>
<h1 className={`
${isCJK ? 'text-4xl sm:text-5xl lg:text-6xl' : 'text-5xl sm:text-6xl lg:text-7xl'}
font-medium tracking-tighter
`}>
{t('headline')}
</h1>
<p className={`
${isCJK ? 'text-base sm:text-lg' : 'text-lg sm:text-xl'}
text-gray-600
`}>
{t('subtitle')}
</p>
{/* Signature always in Latin handwriting font */}
<div lang="en" className="font-handwriting text-3xl">
{t('signature')}
</div>
</section>
);
}
Notes
- The
isCJKcheck useslocale.startsWith('zh')for Chinese variants. Expand tostartsWith('ja') || startsWith('ko')for Japanese/Korean. - Japanese typically needs less size reduction than Chinese (narrower kana characters).
- Korean Hangul syllable blocks are more uniform in width than Chinese characters.
- Always test with actual CJK fonts loaded — system fallback fonts have different metrics.
- The
langattribute approach is a W3C standard mechanism — it's not a hack. See CSS Selectors Level 4: :lang().