# Nextjs Cjk I18N Typography

> CJK (Chinese/Japanese/Korean) typography and locale-switching patterns for Next.js apps using next-intl. Use when: (1) CJK text renders visually too large compared to Latin text at the same font size, (2) CSS :lang(zh) selectors override fonts on elements that should stay Latin (e.g., signatures, brand names), (3) locale switcher with router.push() redirects back to the old locale due to stale NEXT_LOCALE cookie, (4) CJK headings wrap to multiple lines when they should fit on one, (5) environment banners or badges appear on locale-prefixed paths that should be hidden, (6) text-balance CSS causes CJK text to break unnecessarily. Covers font sizing, lang attribute escape hatch, whitespace-nowrap vs text-balance, window.location.href for locale switching, and locale-aware path matching.

- Skill: `hubeiqiao/nextjs-cjk-i18n-typography` (Agent Skill)
- Install (CLI): `npx skillmds@latest add hubeiqiao/nextjs-cjk-i18n-typography`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hubeiqiao/nextjs-cjk-i18n-typography/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Design & Media
- Author: hubeiqiao (https://skillmd.com/u/hubeiqiao)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/hubeiqiao/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-intl` with 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:

```tsx
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:

```tsx
// 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):

```tsx
<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:

```tsx
// 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:

```tsx
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:

```tsx
// 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

1. **Font sizing**: Compare CJK and English pages side-by-side — headlines should
   have similar visual weight despite different character sizes
2. **Lang attribute**: Inspect the element in DevTools → Computed styles should
   show the Latin font, not the CJK font
3. **Locale switching**: Switch from `/zh-CN/about` to English → URL should be
   `/about` with English content (not redirected back)
4. **Text wrapping**: Short CJK headings should display on one line on desktop

## Example

Complete `isCJK` pattern used across a component:

```tsx
'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 `isCJK` check uses `locale.startsWith('zh')` for Chinese variants.
  Expand to `startsWith('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 `lang` attribute approach is a W3C standard mechanism — it's not a hack.
  See [CSS Selectors Level 4: :lang()](https://www.w3.org/TR/selectors-4/#the-lang-pseudo).

## References

- [MDN: :lang() pseudo-class](https://developer.mozilla.org/en-US/docs/Web/CSS/:lang)
- [W3C: CSS Text Module Level 4 — CJK line breaking](https://www.w3.org/TR/css-text-4/)
- [next-intl: useLocale](https://next-intl.dev/docs/usage/configuration#locale)
- [CSS text-wrap: balance — browser considerations](https://developer.chrome.com/docs/css-ui/css-text-wrap-balance)

