A Kurdish font ecosystem — a curated library of 972 Unicode fonts across 20 collections, served through a high-performance Next.js web platform and a zero-dependency npm CLI tool.
Kurdish digital typography has long suffered from fragmentation — fonts scattered across blogs, social media groups, and personal websites with inconsistent naming, broken Unicode mappings, and no package management. krdfont was created to solve this by building a centralized, developer-friendly ecosystem.
The project provides three core interfaces: a web platform for browsing, previewing, and downloading fonts; a CLI tool for programmatic installation into any JavaScript project; and a REST API that serves as the registry backbone for both. Every font in the library supports full Unicode for Kurdish (Sorani & Kurmanji), Arabic, and Persian — ensuring broad RTL language compatibility.
The challenge was not merely collecting fonts, but building infrastructure that could serve 213 MB of binary font data reliably at scale, while keeping the developer experience as simple as a single npx command.
krdfont follows a three-layer architecture: a static font asset layer, a metadata generation layer, and a presentation layer. Each is decoupled and can evolve independently.
The critical insight is the build-time/runtime separation. All filesystem operations (scanning directories, reading font metadata) happen at build time via Node.js scripts. At runtime, the Next.js application reads only from a pre-generated JSON file — no fs module, no dynamic file access, no bundled binaries. This architectural decision is what ultimately enabled the project to deploy on Vercel's serverless infrastructure.
The project is organized as a monorepo with three top-level directories, each with a distinct responsibility:
Project Root krdfonts/ ├── cli/ # npm package ([email protected]) │ ├── bin/krdfonts.js # CLI entry point (#!/usr/bin/env node) │ ├── lib/commands.js # install, remove, init, list commands │ ├── package.json # Zero dependencies, 3.8 kB published │ └── README.md ├── fonts/ # 213 MB — 20 directories, 972 font files │ ├── abd-kurdish-unicode-fonts/ # 23 fonts (.ttf) │ ├── rudaw-fonts/ # 2 fonts (.ttf) │ ├── uniqaidar-kurdish-fonts/ # 150 fonts (.ttf) │ ├── unisalar-kurdish-fonts/ # 255 fonts (.ttf) │ └── ... (16 more collections) └── website/ # Next.js 16 application ├── scripts/ │ ├── generate-fonts-data.js # Build-time metadata scanner │ └── copy-fonts.js # Build-time font copier ├── src/ │ ├── app/ # Next.js App Router pages │ │ ├── api/ # REST API routes │ │ ├── fonts/[slug]/ # Category pages │ │ └── fonts/[slug]/[font]/ # Font detail pages │ ├── components/ # React components │ └── lib/fonts.ts # Runtime font utilities (no fs!) ├── public/fonts/ # Generated at build time └── next.config.ts
| Package | Version | Purpose |
|---|---|---|
| next | 16.2.1 | Framework — App Router, SSR, API routes |
| react / react-dom | 19.2.4 | UI runtime — Server & Client Components |
| tailwindcss | ^4 | Utility-first CSS framework |
| framer-motion | ^12.38.0 | Declarative animations & page transitions |
| lucide-react | ^1.0.1 | Icon library — 1000+ SVG icons as React components |
| react-icons | ^5.6.0 | Brand icons (npm, terminal logos) |
| class-variance-authority | ^0.7.1 | Component variant management |
| clsx / tailwind-merge | ^2.1 / ^3.5 | Conditional class merging utilities |
| @base-ui/react | ^1.3.0 | Headless UI primitives |
| shadcn | ^4.1.0 | Component scaffolding toolchain |
| typescript | ^5 | Static type checking with strict mode |
At build time, two Node.js scripts transform raw font directories into a structured, optimized dataset. This is the core innovation that enables the entire system to work without runtime filesystem access.
The generate-fonts-data.js script scans the fonts/ directory, processing each subdirectory as a "collection." For every font file (.ttf, .otf, .woff2), it extracts:
scripts/generate-fonts-data.js — Font metadata extraction function scanCategories() { const dirs = fs.readdirSync(SOURCE, { withFileTypes: true }); for (const dir of dirs) { const entries = fs.readdirSync(dirPath); for (const entry of entries) { const ext = path.extname(entry).toLowerCase(); if (![".ttf", ".otf", ".woff2"].includes(ext)) continue; fonts.push({ slug: fontSlugFromFilename(entry), // URL-safe identifier name: prettifyFontName(entry), // Human-readable name filename: entry, // Original filename format: formatMap[ext], // truetype | opentype | woff2 weight: guessWeight(entry), // Inferred from filename style: guessStyle(entry), // normal | italic }); } } }
Since Kurdish fonts rarely include OpenType metadata tables, we infer font weight from filename patterns using a cascading keyword match:
scripts/generate-fonts-data.js — Weight detection heuristic function guessWeight(filename) { const lower = filename.toLowerCase(); if (lower.includes("thin") || lower.includes("hairline")) return "100"; if (lower.includes("extralight") || lower.includes("ultralight")) return "200"; if (lower.includes("light")) return "300"; if (lower.includes("medium")) return "500"; if (lower.includes("semibold")) return "600"; if (lower.includes("extrabold")) return "800"; if (lower.includes("black") || lower.includes("heavy")) return "900"; if (lower.includes("bold")) return "700"; return "400"; // default: Regular }
The copy-fonts.js script performs a recursive directory copy from fonts/ to website/public/fonts/. This places font files within Next.js's static asset directory, which Vercel's CDN serves directly — bypassing serverless functions entirely.
scripts/copy-fonts.js — Recursive directory copy const SOURCE = path.join(__dirname, "..", "..", "fonts"); const DEST = path.join(__dirname, "..", "public", "fonts"); function copyDir(src, dest) { if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true }); fs.mkdirSync(dest, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { entry.isDirectory() ? copyDir(srcPath, destPath) : fs.copyFileSync(srcPath, destPath); } }
The final output is a structured JSON manifest consumed at runtime:
src/lib/fonts-data.json — Generated manifest (truncated) { "categories": [ { "slug": "abd-kurdish-unicode-fonts", "name": "Abd Kurdish Unicode Fonts", "dirName": "abd-kurdish-unicode-fonts", "languages": ["Kurdish (Sorani)", "Kurdish (Kurmanji)", "Arabic", "Persian"], "fonts": [ { "slug": "abd-hana-bold", "name": "Abd Hana Bold", "filename": "ABD_HANA_BOLD.ttf", "format": "truetype", "weight": "700", "style": "normal" } ], "fontCount": 23 } ], "totalFonts": 972, "generatedAt": "2026-03-25T..." }
The website leverages the Next.js 16 App Router with a hybrid rendering strategy — server components for data fetching and SEO, client components for interactivity.
The runtime fonts.ts module imports the pre-generated JSON file as a static ES module. This is the key to avoiding serverless function bloat:
src/lib/fonts.ts — Runtime font utilities import fontsData from "./fonts-data.json"; export interface IndividualFont { slug: string; name: string; filename: string; format: "truetype" | "opentype" | "woff2"; weight: string; style: string; } const categories = fontsData.categories as FontCategory[]; export function findCategory(slug: string) { return categories.find(c => c.slug === slug); } export function generateFontFaceCss(categorySlug: string) { const category = findCategory(categorySlug); let css = ""; for (const font of category.fonts) { css += `@font-face { font-family: '${font.slug}'; src: url('/fonts/${category.dirName}/${font.filename}') format('${font.format}'); font-display: swap; }`; } return css; }
Each font has a dedicated page with server-side data fetching, CSS injection, and font preloading:
src/app/fonts/[slug]/[font]/page.tsx export default async function FontDetailPage({ params }) { const { slug, font: fontSlug } = await params; const result = findFont(slug, fontSlug); const fontCss = generateFontFaceCss(slug); return ( <> {/* Inline @font-face CSS for immediate availability */} <style dangerouslySetInnerHTML={{ __html: fontCss }} /> {/* Preload the specific font file for faster rendering */} <link rel="preload" href={`/fonts/${result.category.dirName}/${result.font.filename}`} as="font" crossOrigin="anonymous" /> <FontDetailClient ... /> </> ); }
The client-side preview uses the Font Loading API to detect when a font is ready:
src/components/FontLoadingIndicator.tsx export default function FontLoadingIndicator({ fontSlug }) { const [isLoading, setIsLoading] = useState(true); useEffect(() => { const fonts = document.fonts; if (fonts.check(`12px "${fontSlug}"`)) { setIsLoading(false); } else { fonts.addEventListener('load', () => { if (fonts.check(`12px "${fontSlug}"`)) setIsLoading(false); }); } }, [fontSlug]); }
To prevent FOIT (Flash of Invisible Text), fonts cascade through a carefully chosen fallback stack:
export function getFontStack(fontSlug: string): string { return `'${fontSlug}', 'Inter', 'Segoe UI', 'Noto Sans Arabic', 'Arial Unicode MS', system-ui, sans-serif`; }
Combined with font-display: swap in every @font-face rule, this ensures text is always visible — rendered first in Inter (already loaded via Google Fonts), then swapped to the Kurdish font when it arrives.
The API is built entirely with Next.js Route Handlers. It serves three endpoints:
| Endpoint | Method | Description |
|---|---|---|
| /api/fonts.json | GET | Full font registry — category list with all font metadata. Used by the CLI and web UI. |
| /api/font-file/[slug]/[file] | GET | 302 redirect to static font file at /fonts/{dir}/{file}. Immutable caching. |
| /api/download/[slug] | GET | 302 redirect to static font for download. Accepts ?font=filename query. |
| /api/css/[slug] | GET | Returns generated @font-face CSS for a collection. Immutable cache headers. |
The font-file API route uses a 302 redirect to the static asset instead of reading and streaming the file. This eliminates serverless function payload while preserving the clean API URL structure:
src/app/api/font-file/[fontSlug]/[filename]/route.ts export async function GET(_request, { params }) { const { fontSlug, filename } = await params; const dirName = getOriginalDirName(fontSlug); // Redirect to static font file — Vercel CDN handles the rest const staticUrl = `/fonts/${dirName}/${decodedFilename}`; return NextResponse.redirect(new URL(staticUrl, _request.url), { status: 302, headers: { "Cache-Control": "public, max-age=31536000, immutable", "Access-Control-Allow-Origin": "*", }, }); }
The krdfonts CLI is published on npm at 3.8 kB compressed — with zero external dependencies. It uses only Node.js built-in modules: fs, path, https, and http.
$ npx krdfonts install rudaw-fonts # Install entire collection (2 fonts) $ npx krdfonts install rudaw-fonts/rudawbold # Install specific font $ npx krdfonts list # List all 20 collections $ npx krdfonts remove rudaw-fonts # Remove installed fonts $ npx krdfonts init # Initialize fonts directory
The CLI automatically detects the project type and places fonts in the correct directory:
cli/lib/commands.js — Framework-aware font directory detection function detectFontsDir() { const cwd = process.cwd(); // Next.js → public/fonts if (fs.existsSync(path.join(cwd, "next.config.js")) || fs.existsSync(path.join(cwd, "next.config.ts")) || fs.existsSync(path.join(cwd, "next.config.mjs"))) return path.join(cwd, "public", "fonts"); // Vite → public/fonts if (fs.existsSync(path.join(cwd, "vite.config.js")) || fs.existsSync(path.join(cwd, "vite.config.ts"))) return path.join(cwd, "public", "fonts"); // Create React App → public/fonts if (pkg?.dependencies?.["react-scripts"]) return path.join(cwd, "public", "fonts"); // Default → ./fonts return path.join(cwd, "fonts"); }
Since we serve fonts via 302 redirects, the CLI includes a custom fetch implementation that follows redirects recursively:
cli/lib/commands.js — Custom fetch with redirect support function fetch(url) { return new Promise((resolve, reject) => { const client = url.startsWith("https") ? https : http; client.get(url, (res) => { // Follow redirects if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { return fetch(res.headers.location).then(resolve).catch(reject); } const chunks = []; res.on("data", chunk => chunks.push(chunk)); res.on("end", () => resolve(Buffer.concat(chunks))); }); }); }
After downloading, the CLI generates a style.css file with proper @font-face declarations — ready for direct import:
Generated: public/fonts/rudaw-fonts/style.css @font-face { font-family: 'Rudaw Bold'; src: url('./RudawBold.ttf') format('truetype'); font-weight: 700; font-style: normal; }
The most critical engineering challenge was reducing the serverless function bundle from over 1 GB to 25 MB — a 97.5% reduction — to fit within Vercel's 250 MB unzipped limit.
Next.js uses @vercel/nft (Node File Trace) to statically analyze which files each serverless function needs. When it encountered fs.readFileSync with a path derived from path.join(FONTS_DIR, ...), it conservatively included the entire fonts directory in every function's bundle.
Phase 1 — Eliminate runtime fs operations. The entire fonts.ts module was rewritten to import a pre-generated JSON file instead of scanning the filesystem. This removed NFT's reason to include any font files.
Phase 2 — Static asset serving. Font files are copied to public/fonts/ at build time and served directly by Vercel's CDN. API routes were rewritten to return 302 redirects to these static URLs.
Phase 3 — Explicit exclusions. The Next.js config was updated with outputFileTracingExcludes as a safety net:
next.config.ts — File tracing exclusions const nextConfig: NextConfig = { outputFileTracingExcludes: { "*": [ "fonts/**/*", "**/fonts/**/*", "**/*.ttf", "**/*.otf", "**/*.woff2", "public/fonts/**/*" ], }, };
The UI is built mobile-first with Tailwind CSS breakpoints. Key UX patterns include a responsive header with hamburger menu, dynamic back navigation, and RTL-aware text rendering.
The mobile menu overlay was initially rendered inside the <header> element, which uses backdrop-filter: blur(). This creates a new stacking context in CSS, trapping the overlay behind the blur layer. The fix was to render the overlay as a sibling using a React Fragment:
src/components/Header.tsx — Stacking context escape return ( <> <header className="sticky top-0 z-50 bg-black/80 backdrop-blur-xl"> {/* ... nav content ... */} </header> {/* Overlay OUTSIDE header — escapes backdrop-blur stacking context */} {open && ( <div className="fixed inset-0 top-14 z-[9999]" style={{ backgroundColor: '#000000' }} > {/* Mobile nav links */} </div> )} </> );
The header dynamically computes the "back" link based on the current URL pathname, enabling contextual breadcrumb-style navigation without a global state manager:
const pathname = usePathname(); const parts = pathname.split("/").filter(Boolean); // /fonts/category/font → back to /fonts/category // /fonts/category → back to / const backHref = parts.length >= 3 ? `/${parts[0]}/${parts[1]}` : "/";
All 20 collections with font counts and format details:
| Collection | Fonts | Slug |
|---|---|---|
| UniSalar Kurdish Fonts | 255 | unisalar-kurdish-fonts |
| UniQaidar Kurdish Fonts | 150 | uniqaidar-kurdish-fonts |
| Sarchia Fonts | 100 | sarchia-fonts |
| KurdFonts Unicode Set 2 | 95 | kurdfonts-unicode-set-2 |
| KurdFonts Unicode Set 3 | 55 | kurdfonts-unicode-set-3 |
| Rabar Kurdish Unicode Fonts | 44 | rabar-kurdish-unicode-fonts |
| Nizar Kurdish Unicode Fonts | 39 | nizar-kurdish-unicode-fonts |
| UniKurd Kurdish Fonts | 38 | unikurd-kurdish-fonts |
| KurdFonts Unicode Set 5 | 30 | kurdfonts-unicode-set-5 |
| UniMahan Kurdish Unicode Fonts | 30 | unimahan-kurdish-unicode-fonts |
| Sirwan Kurdish Unicode Fonts | 29 | sirwan-kurdish-unicode-fonts |
| KurdFonts Unicode Set 4 | 26 | kurdfonts-unicode-set-4 |
| ABD Kurdish Unicode Fonts | 23 | abd-kurdish-unicode-fonts |
| Shasenem Fonts | 22 | shasenem-fonts |
| KurdFonts Unicode Set 1 | 20 | kurdfonts-unicode-set-1 |
| KurdFonts Unicode Set 6 | 8 | kurdfonts-unicode-set-6 |
| Kurdistan 24 Fonts | 2 | kurdistan-24-fonts |
| NRT TV Fonts | 2 | nrt-tv-fonts |
| Rudaw Fonts | 2 | rudaw-fonts |
| Speda TV Fonts | 2 | speda-tv-fonts |
The build runs three sequential stages, orchestrated via npm scripts:
package.json — Build scripts { "scripts": { "prebuild": "node scripts/generate-fonts-data.js && node scripts/copy-fonts.js", "build": "node scripts/generate-fonts-data.js && node scripts/copy-fonts.js && next build", "dev": "node scripts/generate-fonts-data.js && node scripts/copy-fonts.js && next dev" } }
vercel.json { "buildCommand": "npm run build", "outputDirectory": ".next", "functions": { "src/app/api/**/*.ts": { "maxDuration": 10 } } }
| Asset Type | Cache-Control | Served By |
|---|---|---|
| Font files (.ttf, .otf, .woff2) | public, max-age=31536000, immutable | Vercel Edge CDN |
| CSS API responses | public, max-age=31536000, immutable | Serverless Function |
| Registry JSON | force-dynamic | Serverless Function |
| HTML pages | Vercel default (ISR-capable) | Edge + Origin |
Build artifacts are excluded from version control:
# Generated at build time — never committed
/public/fonts
/src/lib/fonts-data.json
krdfont demonstrates that a niche cultural infrastructure project can be built with modern web tooling at production quality. The key engineering decisions — build-time metadata generation, static asset serving, and zero-dependency CLI design — enabled a system that scales while remaining simple to maintain.