skills/hubeiqiao/skills/nextjs-cjk-i18n-typography

nextjs-cjk-i18n-typography

Installation
SKILL.md

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:

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

  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:

'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().

References

Weekly Installs
1
First Seen
7 days ago