Building qurandaily.org: 37,000 pages statically prerendered with Next.js 15 + next-intl

작성자

카테고리:

← 피드로
DEV Community · Shahrukh Khan · 2026-09-29 개발(SW)

Last week I shipped qurandaily.org, an ad-free Quran + hadith + duas site with content in six languages (English, Indonesian, Arabic, Urdu, Turkish, French). Every one of its ~37,000 pages is fully prerendered at build time. No SSR on the hot path. No database call between the reader and an ayah. In this post I want to walk through three decisions I had to make and the exact code / config for each, because the patterns generalize to any large multi-locale content site.

The three parts:

  1. Static prerender for 6,236 records × 6 locales via generateStaticParams
  2. Sitemap sharding to avoid the SWC minifier ceiling
  3. Cloudflare Full mode with a self-signed 15-year origin cert

1. generateStaticParams at 6,236 × 6

The canonical content is 6,236 ayat. Each renders as its own URL, scoped by locale and surah slug:

/[locale]/quran/[surahSlug]/[verse]

Enter fullscreen mode Exit fullscreen mode

Directory layout in the app router:

app/
  [locale]/
    quran/
      [surahSlug]/
        [verse]/
          page.tsx

Enter fullscreen mode Exit fullscreen mode

The page.tsx exports two functions Next uses at build time to enumerate every route it should emit:

// app/[locale]/quran/[surahSlug]/[verse]/page.tsx
import { locales } from '@/i18n/config';
import { getSurahs } from '@/data/quran';

export const dynamicParams = false; // 404 anything not prebuilt
export const dynamic = 'force-static';

export async function generateStaticParams() {
  const surahs = await getSurahs(); // 114 surahs, each with ayat count
  const params: {
    locale: string;
    surahSlug: string;
    verse: string;
  }[] = [];

  for (const locale of locales) {          // 6
    for (const s of surahs) {              // 114
      for (let v = 1; v <= s.ayatCount; v++) { // 6,236 total
        params.push({
          locale,
          surahSlug: s.slug,
          verse: String(v),
        });
      }
    }
  }
  return params;
}

Enter fullscreen mode Exit fullscreen mode

That yields 6 × 6,236 = 37,416 static routes for the Quran alone. Add hadith, duas, 99 Names, prayer-time city pages, and learning pages, and you’re just under 40k emitted HTML files.

Two things worth noting:

  • dynamicParams = false is important. It means anything not in generateStaticParams returns a 404, which is what you want for a canonical corpus. No accidental live rendering.
  • getSurahs() reads local JSON derived from the quran.com Uthmani source. No network call at build. Content build time is roughly 90 seconds cold on a decent laptop, most of which is Next writing files, not data fetching.

2. Sitemap sharding (and the SWC minifier bug)

My first attempt at a sitemap used Next’s app/sitemap.ts convention with all 37k URLs generated in one function. It built, but the build ran out of memory on my VPS during route compilation. The SWC minifier chokes on the resulting bundle around the 30k-URL mark — I saw the same OOM signature that’s reported in a few open GitHub issues against next/sitemap.

Fix: one sitemap index at /sitemap.xml, six locale-specific sub-sitemaps behind it, each streamed by a route handler.

app/
  sitemap.xml/route.ts        // index, lists 6 child sitemaps
  sitemap-en.xml/route.ts
  sitemap-id.xml/route.ts
  sitemap-ar.xml/route.ts
  sitemap-ur.xml/route.ts
  sitemap-tr.xml/route.ts
  sitemap-fr.xml/route.ts

Enter fullscreen mode Exit fullscreen mode

Index:

// app/sitemap.xml/route.ts
export const dynamic = 'force-static';

const LOCALES = ['en', 'id', 'ar', 'ur', 'tr', 'fr'] as const;
const BASE = 'https://qurandaily.org';

export function GET() {
  const body =
    '<?xml version="1.0" encoding="UTF-8"?>\n' +
    '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
    LOCALES.map(
      (l) => `  <sitemap><loc>${BASE}/sitemap-${l}.xml</loc></sitemap>`
    ).join('\n') +
    '\n</sitemapindex>\n';

  return new Response(body, {
    headers: { 'content-type': 'application/xml' },
  });
}

Enter fullscreen mode Exit fullscreen mode

Per-locale child (simplified):

// app/sitemap-en.xml/route.ts
import { getSurahs } from '@/data/quran';

export const dynamic = 'force-static';

export async function GET() {
  const surahs = await getSurahs();
  const urls: string[] = [];
  for (const s of surahs) {
    for (let v = 1; v <= s.ayatCount; v++) {
      urls.push(
        `https://qurandaily.org/en/quran/${s.slug}/${v}`
      );
    }
  }

  const body =
    '<?xml version="1.0" encoding="UTF-8"?>\n' +
    '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
    urls
      .map((u) => `  <url><loc>${u}</loc></url>`)
      .join('\n') +
    '\n</urlset>\n';

  return new Response(body, {
    headers: { 'content-type': 'application/xml' },
  });
}

Enter fullscreen mode Exit fullscreen mode

Peak build memory went from an OOM at ~1.6GB to a steady ~600MB. Each locale sitemap ships as its own static file after the build. Google fetched all six within 24 hours of pushing.

3. Cloudflare Full mode + self-signed 15-year origin cert

The site is on a VPS with nginx in front. Cloudflare is in Full (not Full Strict) mode. In Full mode, Cloudflare requires TLS to the origin but does NOT validate the origin’s certificate chain. That means a self-signed cert is fine — and if I make it 15 years long, I never have to think about it again. No certbot cron, no ACME failure notifications at 2am.

Generate the cert:

openssl req -x509 -nodes \
  -newkey rsa:2048 \
  -days 5478 \
  -keyout /etc/nginx/certs/origin.key \
  -out    /etc/nginx/certs/origin.crt \
  -subj   "/CN=qurandaily.org"

Enter fullscreen mode Exit fullscreen mode

(5,478 days ≈ 15 years.)

nginx server block:

server {
    listen 443 ssl http2;
    server_name qurandaily.org www.qurandaily.org;

    ssl_certificate     /etc/nginx/certs/origin.crt;
    ssl_certificate_key /etc/nginx/certs/origin.key;

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    root /path/to/build/out;
    index index.html;

    location / {
        try_files $uri $uri/ $uri.html =404;
    }

    # long-cache for hashed assets
    location /_next/static/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}

Enter fullscreen mode Exit fullscreen mode

Two things Cloudflare handles on top:

  • The public-facing TLS cert (auto-renewed by Cloudflare).
  • Edge caching. Because every response is a static file with a stable URL, the default cache rules do exactly what you want.

If you want Full Strict later, swap the self-signed cert for a Cloudflare Origin CA cert (also free, also 15-year) — same config, one file swap.

Wrapping up

Static prerender is the boring correct answer for content that doesn’t change. The whole build ships as HTML files, the CDN does the hard work, and you spend zero time maintaining a runtime. If you want to see the output, the site is at qurandaily.org — ad-free, no login, no tracking, six languages. Feedback on the render setup or the edge config welcome, especially if you’ve hit the SWC sitemap issue and solved it differently.

원문에서 계속 ↗