How we built a high-performance universal Quran rendering library that supports word-by-word display, multiple mushaf layouts, and extensibility for tafsirs and translations.
Displaying Quran pages on the web is deceptively complex. Unlike regular text, the Quran has:
When we started building Open Quran View, we wanted to create something that any developer could drop into their project and get a professional Quran reader with minimal effort.
This is the story of how we got there.
Our first attempt was straightforward: package all the data directly in the library.
open-quran-view/
├── src/
│ └── data/
│ ├── pages/
│ │ ├── page-1.json
│ │ ├── page-2.json
│ │ └── ...
│ └── fonts/
│ ├── page-1.woff2
│ └── ...
What we shipped:
{
"page": 1,
"verses": [
{
"verseKey": "1:1",
"words": [
{ "text": "بِسْمِ", "position": 1, "line": 2 },
{ "text": "اللَّهِ", "position": 2, "line": 2 }
]
}
]
}
This approach seemed perfect in development. Then reality hit:
| Problem | Impact |
|---|---|
| Bundle Size | 100+ MB for all pages and fonts |
| Build Times | 10+ minutes to process all files |
| Version Updates | Every mushaf update meant a new npm release |
| CDN Complexity | How to serve 604 fonts efficiently? |
The npm package became unwieldy. Developers installing the library faced massive download times, and CI/CD pipelines groaned under the weight.
We explored standardized Quran data formats that promised easier distribution:
{
"version": "1.0",
"mushaf": "hafs-v2",
"pages": [...]
}
The Quran Foundation API (api.quran.foundation) provides the most complete source of Quranic data available:
https://apis.quran.foundation/content/api/v4
async function getAccessToken(clientId: string, clientSecret: string): Promise<string> {
const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const response = await fetch('https://oauth2.quran.foundation/oauth2/token', {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'grant_type=client_credentials&scope=content'
});
const data = await response.json();
return data.access_token;
}
| Endpoint | Purpose |
|---|---|
/chapters |
Get all 114 surahs with metadata |
/juzs |
Get all 30 juz with verse ranges |
/verses/by_page/{page} |
Get verses for a specific page |
/pages/lookup |
Lookup page boundaries for any verse |
/translations |
Get available translations |
/tafsirs |
Get available tafsirs |
The API provides access to multiple authoritative mushafs:
| ID | Name | Narrator | Pages | Special Features |
|---|---|---|---|---|
| 1 | QCF V2 | Hafs via Warsh | 604 | Industry standard |
| 2 | QCF V1 | Hafs via Warsh | 604 | Legacy version |
| 5 | KFGQPC | Hafs | 604 | King Fahd Complex |
| 19 | QCF V4 | Hafs via Shu’bah | 604 | Tajweed colors |
This was the game-changer. The API returns complete word-level data:
{
"verses": [
{
"id": 1,
"verseKey": "1:1",
"words": [
{
"id": 1,
"position": 1,
"pageNumber": 1,
"lineNumber": 2,
"textQpcHafs": "بِسْمِ",
"codeV2": "ﱁ",
"codeV4": "ﱂ"
},
{
"id": 2,
"position": 2,
"pageNumber": 1,
"lineNumber": 2,
"textQpcHafs": "اللَّهِ",
"codeV2": "﷽"
}
]
}
]
}
Every word includes:
textQpcHafs) for fallback renderingcodeV2, codeV4) for precise positioningWe realized: fetch once at build time, use forever at runtime.
Instead of calling the API every time a user loads a page, we generate static files during the build process. This gives us:
Quran Foundation API
│
▼
┌─────────────────┐
│ 1. Metadata │───► src/data/metadata/
│ (surahs, juz) │ ├── surahs.json
└─────────────────┘ └── juz.json
│
▼
┌─────────────────┐
│ 2. Page Data │───► src/data/pages/
│ (604 pages) │ ├── hafs-v2/pages.json
└─────────────────┘ ├── hafs-v4/pages.json
│ └── hafs-unicode/pages.json
▼
┌─────────────────┐
│ 3. Download │───► src/data/fonts/
│ Fonts │ ├── hafs-v2/ (604 .woff2)
└─────────────────┘ └── hafs-v4/ (604 .woff2)
│
▼
┌─────────────────────┐
│ 4. Static Assets │───► src/core/static/
│ (URL mappings) │ ├── fonts.ts (1200+ entries)
└─────────────────────┘ └── data.ts
yarn generate:metadata
Generates:
// surahs.json
[
{
"id": 1,
"nameSimple": "Al-Fatihah",
"nameArabic": "ٱلْفَاتِحَة",
"versesCount": 7,
"revelationPlace": "makkah",
"pages": [1, 1]
}
// ... 113 more
]
yarn generate:pages
For each mushaf, fetches all 604 pages with word-level data.
yarn generate:fonts
Downloads fonts from:
https://verses.quran.foundation/fonts/quran/hafs/v2/woff2/p{N}.woff2https://verses.quran.foundation/fonts/quran/hafs/v4/colrv1/woff2/p{N}.woff2yarn generate:static
Creates TypeScript files with pre-resolved URLs:
// src/core/static/fonts.ts
export const staticFonts = {
"hafs-v2": {
1: new URL("../../data/fonts/hafs-v2/p1.woff2", import.meta.url).href,
2: new URL("../../data/fonts/hafs-v2/p2.woff2", import.meta.url).href,
// ... 604 entries
}
} as const;
During development, fonts loaded perfectly. In production, they failed:
GET https://app.com/node_modules/open-quran-view/dist/data/fonts/p1.woff2 404
What happened?
The import.meta.url in development resolved to the source file. In production builds, paths changed. We had fonts in our package, but browsers couldn’t find them.
Even when we tried dynamic URL generation, modern bundlers (Vite, Webpack, Rollup) optimize aggressively. They see 604 hardcoded font URLs and think: “This is dead code” or “We can inline these references differently.”
The result? Font references get stripped, renamed, or optimized away entirely. The bundler sees:
const fonts = {
1: new URL("../../data/fonts/p1.woff2", import.meta.url).href,
2: new URL("../../data/fonts/p2.woff2", import.meta.url).href,
// ... 604 entries
};
And optimizes it to something that breaks at runtime.
Instead of resolving URLs at runtime, we resolve them at build time and hardcode the exact strings:
// Before: Runtime resolution (broken)
const fontUrl = new URL(`../../data/fonts/${layout}/p${page}.woff2`, import.meta.url).href;
// After: Pre-generated static URL (works)
const fontUrl = staticFonts[layout][page];
The generated TypeScript file contains hardcoded strings that bundlers cannot optimize away:
// src/core/static/fonts.ts (generated)
export const staticFonts = {
"hafs-v2": {
1: "/_nuxt/fonts/hafs-v2/p1.woff2",
2: "/_nuxt/fonts/hafs-v2/p2.woff2",
3: "/_nuxt/fonts/hafs-v2/p3.woff2",
// ... 604 entries
}
} as const;
By generating actual URL strings at build time, we bypass bundler optimization entirely.
src/data/fonts/We wanted to support any view layer (React, Vue, Angular, vanilla JS) without duplicating logic.
┌─────────────────────────────────────────────────────────┐
│ open-quran-view │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────┐ │
│ │ Core Layer │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────┐ │ │
│ │ │ Data Loader │ │ Font Loader │ │ Lookup │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ • Caching │ │ • Fallbacks │ │ • Verse │ │ │
│ │ │ • Fetching │ │ • Preload │ │ • Page │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ View Layer │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ React │ │ Web Component│ │ │
│ │ │ Component │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Data Loading:
class DataLoader {
private cache: Map<string, any> = new Map();
async getPage(layout: MushafLayout, page: number): Promise<PageData> {
const key = `${layout}-${page}`;
if (this.cache.has(key)) {
return this.cache.get(key);
}
const data = await this.fetchPage(layout, page);
this.cache.set(key, data);
return data;
}
}
Font Loading:
class FontLoader {
private loadedFonts: Map<string, FontFace> = new Map();
async loadFont(layout: MushafLayout, page: number): Promise<FontFace> {
const key = `${layout}-${page}`;
if (this.loadedFonts.has(key)) {
return this.loadedFonts.get(key);
}
const url = getFontUrl(layout, page);
const fontFace = new FontFace(`Quran-${layout}-${page}`, `url(${url})`);
await fontFace.load();
document.fonts.add(fontFace);
this.loadedFonts.set(key, fontFace);
return fontFace;
}
}
Rendering:
Events:
interface WordInfo {
id: number;
surahNumber?: number;
ayahNumber?: number;
text?: string;
}
// Event payload
{
detail: {
word: WordInfo,
page: number,
layout: 'hafs-v2'
}
}
Want to add Vue support? Simply implement the view interface:
import { Core, MushafLayout, PageData } from 'open-quran-view/core';
class QuranViewerVue {
private core: Core;
renderPage(page: number) {
const data = this.core.getPageData(page);
// Vue-specific rendering logic
}
}
All the complex data fetching, font loading, and navigation logic is already handled by the core.
import { OpenQuranView } from 'open-quran-view/view';
function App() {
return (
<OpenQuranView
page={1}
width={600}
height={850}
mushafLayout="hafs-v2"
theme="light"
onWordClick={(word) => console.log(word)}
onPageChange={(page) => console.log(page)}
/>
);
}
<script type="module">
import { registerOpenQuranView } from 'open-quran-view/view/web';
registerOpenQuranView();
</script>
<open-quran-view
page="1"
mushaf-layout="hafs-v2"
width="600"
height="850"
theme="light"
></open-quran-view>
Both versions expose the same functionality:
| Feature | React Prop | Web Attribute |
|---|---|---|
| Page number | page={1} |
page="1" |
| Layout | mushafLayout="hafs-v2" |
mushaf-layout="hafs-v2" |
| Load event | onLoad={handler} |
load event |
| Word click | onWordClick={handler} |
wordclick event |
| Page change | onPageChange={handler} |
pagechange event |
Our default mushaf, widely used in print and digital applications:
<OpenQuranView mushafLayout="hafs-v2" />
For those who want color-coded tajweed (Quran recitation rules):
<OpenQuranView mushafLayout="hafs-v4" />
For environments where Quranic fonts aren’t available:
<OpenQuranView mushafLayout="hafs-unicode" />
The Quran Foundation API provides rich data beyond just text:
/translations → Get available translations
/tafsirs → Get available tafsirs
/verses/{id}/tafsirs → Get tafsir for a specific verse
Each word in our data structure is individually addressable:
interface WordInfo {
id: number; // Unique identifier
surahNumber: number; // Chapter (1-114)
ayahNumber: number; // Verse in chapter
position: number; // Position in verse
text: string; // Arabic text
}
<OpenQuranView
page={1}
onWordClick={(word) => {
fetchTafsir(word.surahNumber, word.ayahNumber)
.then(tafsir => showTafsirModal(tafsir));
}}
/>
// Available translations from API
const translations = [
{ id: 20, name: "Sahih International" },
{ id: 161, name: "Muhammad Taqi Usmani" },
{ id: 95, name: "Abdul Haleem" }
];
// Fetch verse with translation
async function getVerseWithTranslation(
verseKey: string,
translationId: number
) {
const response = await fetch(
`/verses/by_key/${verseKey}?translations=${translationId}`
);
return response.json();
}
When developers install the package, they need fonts and data. We solved this with the prepare lifecycle:
{
"scripts": {
"prepare": "tsx scripts/generate-all.ts && yarn build"
}
}
npm install open-quran-view
prepare script
dist/
├── core/
│ ├── static/
│ │ ├── fonts.ts # Static font URLs
│ │ └── data.ts # Static data URLs
│ ├── data-loader.ts
│ └── font-loader.ts
├── data/
│ ├── fonts/
│ │ ├── hafs-v2/ # 604 font files
│ │ └── hafs-v4/ # 604 font files
│ ├── pages/
│ │ ├── hafs-v2/pages.json
│ │ └── hafs-v4/pages.json
│ └── metadata/
│ ├── surahs.json
│ └── juz.json
└── view/
├── react/ # React component bundle
└── web/ # Web component bundle
Building data pipelines during build time beats runtime API calls for stability and performance.
Pre-generating URLs eliminates the “works in dev, breaks in production” syndrome.
Separating core logic from view rendering made adding new views trivial.
Investing in word-by-word data structure unlocked tafsir integration, translations, and interactive features.
Different mushafs have different glyph systems, fonts, and coordinate systems. The architecture must accommodate all of them.
Building Open Quran View taught us that Quran rendering is deceptively complex. The journey from local files to Quran Foundation API was one of continuous learning.
Key takeaways:
// One component, full functionality
<OpenQuranView
page={1}
mushafLayout="hafs-v2"
theme="dark"
onWordClick={handleWordClick}
onLongPress={showTafsir}
/>
The library is now used by developers building Quran apps, Islamic education platforms, and research projects. Every time someone renders a Quran page with Open Quran View, they’re standing on the shoulders of these lessons learned.
Open Quran View is built with modern tooling to ensure reliability, type safety, and developer experience.
The project uses PNPM workspaces for efficient dependency management:
open-quran-view/
├── package.json # Root workspace config
├── .yarnrc.yml # Yarn workspace definition
├── packages/ # (Future) Separate publishable packages
├── playground/ # Development playgrounds
│ ├── vite-react/ # React playground
│ └── vite-web/ # Web Component playground
├── scripts/ # Build & generation scripts
└── src/ # Main package source
Benefits:
Everything is written in TypeScript with strict mode:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"declaration": true
}
}
Type safety ensures:
We use tsup for fast, zero-config builds:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
onSuccess: async () => {
await copyFonts();
await copyData();
await copyStatic();
}
});
Benefits:
The project includes live playgrounds for testing during development:
yarn playground:setup # Install dependencies for playgrounds
yarn playground:react # Run React playground (localhost:5173)
yarn playground:web # Run Web Component playground (localhost:5174)
The playgrounds:
We use Vitest for fast, modern testing:
yarn test # Run all tests
yarn test:watch # Watch mode for TDD
yarn test:coverage # Coverage report
Test categories:
Example test:
// src/core/__tests__/data-loader.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DataLoader } from '../data-loader';
describe('DataLoader', () => {
let loader: DataLoader;
beforeEach(() => {
loader = new DataLoader();
});
it('should cache loaded pages', async () => {
const page1 = await loader.getPage('hafs-v2', 1);
const page1Again = await loader.getPage('hafs-v2', 1);
expect(page1).toBe(page1Again);
});
it('should throw on invalid mushaf', async () => {
await expect(loader.getPage('invalid', 1))
.rejects.toThrow('Unknown mushaf');
});
});
# 1. Clone and install
git clone https://github.com/your-org/open-quran-view.git
cd open-quran-view
yarn install
# 2. Generate data (requires API credentials)
cp .env.example .env
# Add QURAN_CLIENT_ID and QURAN_CLIENT_SECRET
yarn generate:all
# 3. Start development
yarn playground:react
# 4. Make changes to src/
# Playground hot-reloads automatically
# 5. Test your changes
yarn test
# 6. Build for release
yarn build
GitHub Actions runs on every push:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
- run: yarn install
- run: yarn generate:all
- run: yarn test
- run: yarn lint
- run: yarn build
Written for developers building Islamic applications. Jazakum Allahu Khairan for reading.