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:
- Third-party embeds (YouTube/Twitch) destroying Total Blocking Time (TBT) and LCP.
- Build-time
new Date()causing SEO content churn on continuous deployment pipelines. - Search engines taking weeks to discover hundreds of dynamic entity sub-pages.
- 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>
);
}
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:
-
Uncommitted Working-Tree Files: Checks
git status --porcelainand uses the file systemmtimeso local development reflects instant changes. -
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. -
Shallow CI Clones Defense: If running on CI with
git clone --depth=1, runninggit logwould falsely return today's commit for everything. The script checksgit rev-parse --is-shallow-repositoryand preserves the previously committedpageDates.tswithout 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;
}
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 };
}
When changes are detected, it broadcasts concurrently:
- Track A (Bing Webmaster API): Submits batch via API key with automatic fallback to remaining daily quota.
-
Track B (IndexNow Protocol): Broadcasts to
api.indexnow.organdyandex.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"
}
}
-
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/outdirectory, builds a directed graph of allhrefattributes 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.htmlfile, 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
lastmodtimestamps 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
postbuildto guarantee link graph integrity.
Have questions about setting up Git-driven static date pipelines or IndexNow automation? Drop a comment below!
Top comments (1)
curious how you handled the git timestamps without bloating the build time as the wiki grows... those can get messy lol