The problem with beautiful type
Urdu is written in Nastaliq — a calligraphic script that flows diagonally, with deep descenders, stacked diacritics, and context-dependent ligatures that change shape based on adjacent characters. It is one of the most typographically complex scripts in active use.
Rendering Nastaliq on the web requires font files that carry thousands of OpenType shaping rules — lookup tables that tell the browser how to connect letters, where to place dots, how to stack combinations that don't exist in Latin typography. The result: a single weight of Noto Nastaliq Urdu is 234KB. Noto Naskh Arabic (the simpler script we use for body text) is 187KB. Gulzar, a decorative Nastaliq face, is 92KB.
For a bilingual site serving both English and Urdu, this creates a fundamental tension: the typography that makes Urdu beautiful on screen is the same typography that makes pages slow.
What we started with
Our initial font configuration loaded five families in the root layout:
const cormorant = Cormorant_Garamond({
weight: ["300", "400", "500", "600", "700"],
style: ["normal", "italic"],
});
const dmSans = DM_Sans({
weight: ["300", "400", "500", "600", "700"],
style: ["normal", "italic"],
});
const notoNastaliq = Noto_Nastaliq_Urdu({ weight: ["400", "500", "600", "700"] });
const notoNaskh = Noto_Naskh_Arabic({ weight: ["400", "500", "600", "700"] });
const gulzar = Gulzar({ weight: ["400"] });
That's potentially 25+ font files — and the three Arabic-script fonts alone totalled over 500KB.
Next.js's next/font system is excellent: it self-hosts fonts, generates optimised @font-face declarations, and uses display: swap to avoid invisible text. But by default, it also preloads every registered font — injecting <link rel="preload"> tags into the document head. The browser sees these and begins downloading immediately, in parallel with the stylesheet.
On an English page, this meant the browser was dutifully downloading 234KB of Urdu ligature tables before it could paint the hero text. The Arabic fonts sat at the end of a three-link dependency chain:
HTML → CSS (2,378ms) → Nastaliq font (3,151ms)
This single waterfall was the primary reason our LCP was 8.3 seconds on mobile.
The typographic audit
Before changing anything, we needed to understand what we were actually using. A codebase-wide search for every font utility class:
rg "font-urdu|font-naskh|font-gulzar|font-serif|font-sans" --type tsx
The findings:
| Font family | Weights declared | Weights actually used |
|---|---|---|
| Cormorant Garamond | 300, 400, 500, 600, 700 | 400, 500 |
| DM Sans | 300, 400, 500, 600, 700 | 400, 500, 600, 700 |
| Noto Nastaliq Urdu | 400, 500, 600, 700 | 400, 500 |
| Noto Naskh Arabic | 400, 500, 600, 700 | 400, 500 |
| Gulzar | 400 | 400 |
We were loading weights that nothing in the codebase referenced. The 300 and italic variants of DM Sans, the 600 and 700 of both Arabic fonts, the 300/600/700 of Cormorant — all dead weight.
Three decisions
1. Trim to what we use
// Cormorant: 10 files → 4 files
weight: ["400", "500"], style: ["normal", "italic"]
// DM Sans: 10 files → 4 files
weight: ["400", "500", "600", "700"] // no italic
// Nastaliq: 4 files → 2 files
weight: ["400", "500"]
// Naskh: 4 files → 2 files
weight: ["400", "500"]
From ~25 font files to ~12. Each Arabic font file saved is disproportionately impactful — they're 2–5x heavier than their Latin equivalents.
2. Defer Arabic font preloading
The critical insight: on an English page, Arabic fonts aren't needed for initial render. They're needed when the user navigates to an Urdu page, or when Arabic text appears in annotations or quotes. That can happen after the page is already interactive.
const notoNastaliq = Noto_Nastaliq_Urdu({
weight: ["400", "500"],
display: "swap",
preload: false, // 234KB — don't block initial render
});
const notoNaskh = Noto_Naskh_Arabic({
weight: ["400", "500"],
display: "swap",
preload: false, // 187KB
});
const gulzar = Gulzar({
weight: ["400"],
display: "swap",
preload: false, // 92KB
});
preload: false tells Next.js not to inject the <link rel="preload"> tag. The browser still downloads the font when it encounters text that needs it — display: swap ensures the system font shows immediately and swaps to the custom font once loaded — but it no longer blocks painting the English homepage.
The critical rendering path collapsed from 3.1 seconds to under 1 second.
3. Design the RTL experience around the constraints
The font deferral means Urdu pages have a brief flash of system Arabic font before Nastaliq loads. We designed around this:
html[dir="rtl"] h1, html[dir="rtl"] h2, html[dir="rtl"] h3 {
font-family: var(--font-urdu);
}
html[dir="rtl"] body {
line-height: 1.9; /* Higher for Arabic script readability */
}
The system font stack — "Traditional Arabic", serif — is a reasonable fallback on most devices. The swap is visible but not jarring, because we chose a system fallback with similar proportions. And the higher line-height for RTL pages gives the stacked diacritics room to breathe regardless of which font is active.
Why Nastaliq matters enough to carry the weight
The easy optimisation would have been to drop Nastaliq entirely and serve everything in Naskh — a simpler Arabic script that's lighter, more widely supported, and perfectly legible. Many Urdu websites do this.
We didn't, because Nastaliq is not a stylistic preference. It is the standard script of Urdu literary and intellectual tradition. Iqbal's poetry was typeset in Nastaliq. Pakistani newspapers are set in Nastaliq. For an Urdu reader, Naskh feels like reading English in a handwriting font — technically legible, but culturally wrong.
Tadreej is a project grounded in the proposition that Pakistan's intellectual traditions deserve serious institutional support. Serving Iqbal's Reconstruction in Naskh would undermine that proposition at the level of typography. The script is part of the argument.
So we carry the 234KB. We just don't make everyone wait for it.
The numbers
| Metric | Before | After |
|---|---|---|
| Font files loaded | ~25 | ~12 |
| Arabic font payload (initial) | 513KB | 0KB (deferred) |
| Critical rendering chain | 3,151ms | < 800ms |
| LCP (mobile) | 8.3s | 3.2s |
What we'd do differently next time
Subset the fonts. Noto Nastaliq Urdu ships with coverage for Arabic, Persian, Sindhi, Pashto, and several other scripts. We only need the Urdu subset. Tools like glyphanger or fonttools can strip unused Unicode ranges, potentially halving the file size. We haven't done this yet because next/font doesn't support custom subsetting of Google Fonts — but a self-hosted, manually subsetted WOFF2 is on our list.
Variable fonts. Both Noto Nastaliq and Noto Naskh are available as variable fonts with a single file covering all weights. A variable Nastaliq would replace our two weight-specific files with one — at a small size increase per file but a net reduction in total download. The blocker is that next/font/google doesn't yet expose the variable axes for these families.
Locale-aware preloading. The ideal setup would preload Arabic fonts only when the [locale] route parameter is ur. Next.js's middleware-based i18n makes this possible in theory — you could conditionally inject preload hints based on the locale. We haven't built this yet, but it would eliminate even the deferred download on English-only visits.
The principle
Typography on a bilingual site is not a performance problem to be solved — it's a design constraint to be navigated. The goal is not to make the fonts smaller (that would compromise the script). The goal is to make the right fonts available at the right time: Latin fonts preloaded for instant English rendering, Arabic fonts deferred until they're needed, and a graceful system-font fallback that respects the script's proportions.
Every millisecond of load time is a cost. But so is every compromise to the typographic integrity of a script that has carried a civilisation's intellectual tradition for centuries. The craft is in finding the point where both constraints are satisfied.
Built with patience. Set in Nastaliq.