Technical Case Study

krdfont

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.

972 fonts
20 collections
213 MB served
3.8 kB CLI
25 MB build
Software Engineer & Designer
Table of Contents
  1. Project Overview & Motivation
  2. System Architecture
  3. Monorepo Structure
  4. Technology Stack
  5. Font Processing Pipeline
  6. Web Platform Deep-Dive
  7. API Layer & Font Serving
  8. CLI Tool — Zero-Dependency Architecture
  9. Build Optimization: 1 GB → 25 MB
  10. Responsive Design & UX
  11. Font Collections Catalogue
  12. Deployment & Infrastructure
  13. Conclusion & Future Work

Project Overview & Motivation

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.

972
Total Fonts
20
Collections
213 MB
Font Data
3.8 kB
CLI Package
v1.1.1
npm Version
25 MB
Build Output

System Architecture

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.

fonts/
generate-fonts-data.js
fonts-data.json
Next.js App
Vercel CDN
fonts/
copy-fonts.js
public/fonts/
Static Serving
API /fonts.json
CLI fetches registry
CLI downloads fonts
Local project

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.

Monorepo Structure

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

Technology Stack

Next.js 16.2.1
App Router, Server Components, Turbopack, React 19.2.4. Dynamic and static rendering with generateStaticParams.
🎨
Tailwind CSS v4
Utility-first styling with PostCSS integration, custom theming, dark-first design system, responsive breakpoints.
🎬
Framer Motion
Production-ready animations — page transitions, staggered grid reveals, smooth opacity/transform interpolations.
📦
npm CLI (0 deps)
Pure Node.js using only built-in modules: fs, path, https, http. 3.8 kB compressed, supports Node ≥ 16.
🚀
Vercel Edge
Serverless deployment with global CDN, automatic HTTPS, edge caching for static font assets.
🔤
Unicode RTL
Full support for Sorani, Kurmanji, Arabic, and Persian — with bidirectional text rendering via dir="auto".

Full Dependency Manifest

PackageVersionPurpose
next16.2.1Framework — App Router, SSR, API routes
react / react-dom19.2.4UI runtime — Server & Client Components
tailwindcss^4Utility-first CSS framework
framer-motion^12.38.0Declarative animations & page transitions
lucide-react^1.0.1Icon library — 1000+ SVG icons as React components
react-icons^5.6.0Brand icons (npm, terminal logos)
class-variance-authority^0.7.1Component variant management
clsx / tailwind-merge^2.1 / ^3.5Conditional class merging utilities
@base-ui/react^1.3.0Headless UI primitives
shadcn^4.1.0Component scaffolding toolchain
typescript^5Static type checking with strict mode

Font Processing Pipeline

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.

Step 1: Metadata Generation

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
      });
    }
  }
}

Weight Inference Algorithm

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
}

Step 2: Static Asset Copy

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);
  }
}

Output: fonts-data.json

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..."
}

Web Platform Deep-Dive

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.

Runtime Font Access (No fs!)

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;
}

Font Detail Page — Server Component

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 ... />
    </>
  );
}

Dynamic Font Preview with Loading Indicator

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]);
}

Font Stack Fallback Strategy

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.

API Layer & Font Serving

The API is built entirely with Next.js Route Handlers. It serves three endpoints:

EndpointMethodDescription
/api/fonts.jsonGETFull font registry — category list with all font metadata. Used by the CLI and web UI.
/api/font-file/[slug]/[file]GET302 redirect to static font file at /fonts/{dir}/{file}. Immutable caching.
/api/download/[slug]GET302 redirect to static font for download. Accepts ?font=filename query.
/api/css/[slug]GETReturns generated @font-face CSS for a collection. Immutable cache headers.

Font-File Route — Redirect Pattern

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": "*",
    },
  });
}

CLI Tool — Zero-Dependency Architecture

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.

Usage

$ 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

Smart Project Detection

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");
}

HTTP Client — Redirect-Aware Fetch

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)));
    });
  });
}

Auto-Generated CSS

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;
}

Build Optimization: 1 GB → 25 MB

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.

🚨
The Problem: Vercel's serverless functions have a hard 250 MB unzipped size limit. The initial implementation used fs.readFileSync() in API routes to serve font files, causing Next.js to bundle all 213 MB of fonts into every serverless function via its file tracing algorithm (NFT). Combined with node_modules, the total exceeded 1 GB.

Root Cause Analysis

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.

The Solution: Three-Phase Refactor

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/**/*"
    ],
  },
};
Result: Build output dropped from 1,009 MB → 25.43 MB (97.5% reduction). Serverless functions now contain only JavaScript code and the fonts-data.json manifest. Font files are served exclusively via Vercel's edge CDN with immutable cache headers.
1,009 MB
Before
25 MB
After
97.5%
Reduction
0
fs calls at runtime

Responsive Design & UX

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.

Responsive Header with Stacking Context Fix

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>
    )}
  </>
);

Dynamic Back Navigation

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]}`
  : "/";

Font Collections Catalogue

All 20 collections with font counts and format details:

CollectionFontsSlug
UniSalar Kurdish Fonts255unisalar-kurdish-fonts
UniQaidar Kurdish Fonts150uniqaidar-kurdish-fonts
Sarchia Fonts100sarchia-fonts
KurdFonts Unicode Set 295kurdfonts-unicode-set-2
KurdFonts Unicode Set 355kurdfonts-unicode-set-3
Rabar Kurdish Unicode Fonts44rabar-kurdish-unicode-fonts
Nizar Kurdish Unicode Fonts39nizar-kurdish-unicode-fonts
UniKurd Kurdish Fonts38unikurd-kurdish-fonts
KurdFonts Unicode Set 530kurdfonts-unicode-set-5
UniMahan Kurdish Unicode Fonts30unimahan-kurdish-unicode-fonts
Sirwan Kurdish Unicode Fonts29sirwan-kurdish-unicode-fonts
KurdFonts Unicode Set 426kurdfonts-unicode-set-4
ABD Kurdish Unicode Fonts23abd-kurdish-unicode-fonts
Shasenem Fonts22shasenem-fonts
KurdFonts Unicode Set 120kurdfonts-unicode-set-1
KurdFonts Unicode Set 68kurdfonts-unicode-set-6
Kurdistan 24 Fonts2kurdistan-24-fonts
NRT TV Fonts2nrt-tv-fonts
Rudaw Fonts2rudaw-fonts
Speda TV Fonts2speda-tv-fonts

Deployment & Infrastructure

Build Pipeline

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"
  }
}
1. Generate JSON
2. Copy Fonts
3. next build
4. Deploy

Vercel Configuration

vercel.json
{
  "buildCommand": "npm run build",
  "outputDirectory": ".next",
  "functions": {
    "src/app/api/**/*.ts": { "maxDuration": 10 }
  }
}

Cache Strategy

Asset TypeCache-ControlServed By
Font files (.ttf, .otf, .woff2)public, max-age=31536000, immutableVercel Edge CDN
CSS API responsespublic, max-age=31536000, immutableServerless Function
Registry JSONforce-dynamicServerless Function
HTML pagesVercel default (ISR-capable)Edge + Origin

.gitignore — Generated Files

Build artifacts are excluded from version control:

# Generated at build time — never committed
/public/fonts
/src/lib/fonts-data.json

Conclusion & Future Work

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.

Key Technical Achievements

Future Roadmap

☀️
krdfont is live at krdfonts.yadqasim.dev. Install via npx krdfonts list. View the project on yadqasim.dev.