DEV Community

Cover image for Building a 100/100 Lighthouse Static Wiki with Next.js: 4 Production Lessons in Media Facades, Git Timestamps, and Instant Indexing
Chen Tao
Chen Tao

Posted on

Building a 100/100 Lighthouse Static Wiki with Next.js: 4 Production Lessons in Media Facades, Git Timestamps, and Instant Indexing

Building a 100/100 Lighthouse Static Wiki with Next.js: 4 Production Lessons in Media Facades, Git Timestamps, and Instant Indexing

Static site generation (SSG) with Next.js has become the default choice for content databases, game wikis, and documentation hubs. On paper, running next build with output: 'export' generates pure HTML/CSS and gives you blazing fast time-to-first-byte (TTFB).

In production, however, static wikis face four real-world engineering bottlenecks:

  1. Third-party embeds (YouTube/Twitch) destroying Total Blocking Time (TBT) and LCP.
  2. Build-time new Date() causing SEO content churn on continuous deployment pipelines.
  3. Search engines taking weeks to discover hundreds of dynamic entity sub-pages.
  4. Broken internal link graphs and orphan pages slipping through static export builds undetected.

While building Steal an Egg Wiki — a high-performance database and guide engine for Roblox game mechanics — I developed a set of lightweight, zero-dependency Node.js and React patterns to solve these exact issues.

Here are the 4 battle-tested architectural lessons.


1. Zero-TBT Video Facade Pattern (Saving 800ms of JS Execution)

Embedding YouTube videos into gaming guide pages is essential for user engagement. But dropping standard <iframe> tags into the DOM is devastating to Core Web Vitals:

  • A single standard YouTube iframe downloads ~700KB of JavaScript, parses player bundles, and triggers multiple third-party network connections before user interaction.
  • Mobile Lighthouse performance drops from 98+ to sub-60 due to high Total Blocking Time (TBT).

The Fix: Lazy Facade Component with Local WebP Caching

Instead of mounting the iframe during hydration, we render a lightweight static facade (local WebP thumbnail + pure SVG play button). The actual iframe is only injected when the user explicitly clicks the play button.

// src/components/YouTubeEmbed.tsx
"use client";

import { useState } from "react";

function videoIdFromUrl(url: string): string {
  const m = url.match(
    /(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|shorts\/))([\w-]{11})/
  );
  return m ? m[1] : url;
}

export default function YouTubeEmbed({
  url,
  title,
  className = "",
}: {
  url: string;
  title?: string;
  className?: string;
}) {
  const [loaded, setLoaded] = useState(false);
  const videoId = videoIdFromUrl(url);

  return (
    <div className={`group overflow-hidden rounded-[14px] border border-zinc-200 bg-zinc-900 dark:border-zinc-800 ${className}`}>
      <div className="relative aspect-video w-full">
        {loaded ? (
          <iframe
            className="h-full w-full"
            src={`https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0&modestbranding=1`}
            title={title ?? "YouTube video player"}
            allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
            referrerPolicy="strict-origin-when-cross-origin"
            allowFullScreen
          />
        ) : (
          <>
            <img
              src={`/images/yt/${videoId}.webp`}
              onError={(e) => {
                const target = e.currentTarget;
                if (!target.src.includes('i.ytimg.com')) {
                  target.src = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
                }
              }}
              alt={title ?? "Video thumbnail"}
              loading="lazy"
              decoding="async"
              fetchPriority="low"
              width={480}
              height={270}
              className="h-full w-full object-cover"
            />
            <button
              type="button"
              onClick={() => setLoaded(true)}
              aria-label={`Play video: ${title ?? "YouTube video"}`}
              className="absolute inset-0 flex items-center justify-center bg-black/30 transition-colors group-hover:bg-black/40"
            >
              <span className="flex h-14 w-14 items-center justify-center rounded-full bg-orange-500 text-white shadow-lg transition-transform group-hover:scale-110">
                <svg viewBox="0 0 24 24" className="ml-0.5 h-6 w-6 fill-current" aria-hidden="true">
                  <path d="M8 5v14l11-7z" />
                </svg>
              </span>
            </button>
          </>
        )}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Prebuild Thumbnail Scraper

To guarantee 0 external network requests during initial paint, we run a prebuild script (scripts/optimize-media.mjs) that extracts YouTube IDs from all Markdown and JSON content files, fetches the thumbnail once, and converts it into a 480x270 WebP in /public/images/yt/.


2. Preventing "SEO Content Churn" with Git-Driven Timestamps

When building static sites on CI/CD (GitHub Actions or Cloudflare Pages), many developers generate metadata dates using new Date().toISOString().

This is an SEO disaster. Every time you push a minor typo fix, the build timestamp updates across all 500+ static HTML pages and your sitemap.xml. Search engine crawlers (Googlebot, Bingbot) detect that all URLs claim to be updated today, re-crawl indiscriminately, hit crawl budget limits, and lose trust in your lastmod headers.

The Fix: Multi-Tier Git Log Introspection

We built scripts/gen-content-dates.mjs which runs before next build. It analyzes the repository's commit history per file and generates a static src/data/pageDates.ts map:

  1. Uncommitted Working-Tree Files: Checks git status --porcelain and uses the file system mtime so local development reflects instant changes.
  2. Committed Files: Executes git log -1 --format=%cs -- "<file-path>" to extract the exact date the page (or its linked JSON data dependency) was last modified in Git.
  3. Shallow CI Clones Defense: If running on CI with git clone --depth=1, running git log would falsely return today's commit for everything. The script checks git rev-parse --is-shallow-repository and preserves the previously committed pageDates.ts without touching it.
// Excerpt from scripts/gen-content-dates.mjs
function getFileDate(relFilePath) {
  const normalizedRel = relFilePath.replace(/\\/g, '/');
  const absPath = path.join(ROOT, normalizedRel);
  if (!fs.existsSync(absPath)) return null;

  // 1. Working-tree edits take immediate precedence
  if (PORCELAIN_FILES.has(normalizedRel)) {
    try {
      const mdate = formatLocalDate(fs.statSync(absPath).mtime);
      if (/^\d{4}-\d{2}-\d{2}$/.test(mdate)) return mdate;
    } catch {}
  }

  // 2. Git commit history
  if (!IS_SHALLOW) {
    try {
      const gitDate = runGit(`log -1 --format=%cs -- "${normalizedRel}"`);
      if (gitDate && /^\d{4}-\d{2}-\d{2}$/.test(gitDate)) return gitDate;
    } catch {}
  }

  return SITE_PUBLISHED;
}
Enter fullscreen mode Exit fullscreen mode

Now, updating pets.json only bumps the lastmod timestamp for /pets/ and its dynamic detail routes (/pets/[slug]/), leaving the rest of the site completely stable.


3. Dual-Track IndexNow & Bing Batch Submission

Waiting for search engines to crawl dynamically generated static pages can take 2 to 4 weeks. The IndexNow protocol and Bing Webmaster API allow you to push URL batches directly to search engine queues instantly.

However, hitting submission APIs naively causes rate limit errors and wastes daily quotas.

The Fix: Incremental Diffing against .indexnow-state.json

Our post-deploy script (scripts/submit-indexnow.mjs) builds an in-memory map of all URLs and their respective lastmod timestamps, diffs them against a local state file, and submits only the URLs that actually changed.

// Excerpt from scripts/submit-indexnow.mjs
function getIncrementalUrls(urlMap, force) {
  let state = {};
  if (fs.existsSync(STATE_FILE)) {
    try {
      state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
    } catch (e) {
      state = {};
    }
  }

  const urlsToSubmit = [];
  for (const [url, lastmod] of urlMap.entries()) {
    // Submit only if URL is new or lastmod changed
    if (force || state[url] !== lastmod) {
      urlsToSubmit.push({ url, lastmod });
    }
  }

  return { state, urlsToSubmit };
}
Enter fullscreen mode Exit fullscreen mode

When changes are detected, it broadcasts concurrently:

  1. Track A (Bing Webmaster API): Submits batch via API key with automatic fallback to remaining daily quota.
  2. Track B (IndexNow Protocol): Broadcasts to api.indexnow.org and yandex.com/indexnow.

On success, .indexnow-state.json updates and commits to the repository, ensuring zero duplicate submissions on the next release.


4. Build-Time Pre/Post Pipeline Auditing (Zero Broken Links & Zero Orphans)

A common pitfall in SSG is producing "orphan pages" — dynamic detail pages (e.g. /pets/golden-dragon/) generated during generateStaticParams that lack inbound links from navigation menus or category overview pages. Search engines penalize or ignore orphan pages.

We incorporated automated audit gates into package.json:

{
  "scripts": {
    "prebuild": "node scripts/audit-data.mjs && node scripts/gen-content-dates.mjs && node scripts/optimize-media.mjs",
    "build": "next build",
    "postbuild": "node scripts/audit-links.mjs && node scripts/audit-orphans.mjs"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • audit-data.mjs: Validates that every entity slug in JSON datasets has valid titles, stats, and non-empty metadata before Next.js compiles.
  • audit-orphans.mjs: Traverses the exported /out directory, builds a directed graph of all href attributes across HTML files, and fails the build with an error if any page has 0 inbound links.
  • audit-links.mjs: Ensures every internal hyperlink maps to a valid .html file, catching broken dynamic routes before deployment.

The Results

By shifting media processing, link audits, and timestamp generation into deterministic pre/post build scripts:

  • Lighthouse Performance Score: 100 / 100 across Desktop and Mobile on stealanegg-roblox.wiki.
  • Zero Layout Shift (CLS: 0.00): Enforced aspect ratio boxes on video and image facades.
  • Instant Indexing: New game code updates and pet tiers are indexed by Bing and Yandex within minutes of deployment instead of weeks.

Summary Checklist for your next Next.js SSG Project:

  • [x] Replace direct video <iframe> tags with lightweight client facades and pre-cached thumbnails.
  • [x] Decouple page lastmod timestamps from build time; bind them to Git history or data file dependencies.
  • [x] Set up an incremental IndexNow diffing state machine for continuous search engine pinging.
  • [x] Run static orphan and 404 audit scripts in postbuild to guarantee link graph integrity.

Have questions about setting up Git-driven static date pipelines or IndexNow automation? Drop a comment below!

Top comments (1)

Collapse
 
citedy profile image
Dmitry Sergeev

curious how you handled the git timestamps without bloating the build time as the wiki grows... those can get messy lol