1. The Problem: Modern Web Tools Are Quietly Broken
Open any random "free online tool" on the first page of Google. Here is the experience you almost always get:
- Bloat. A single text-box utility ships 2–4 MB of JavaScript, a client-side router, an analytics SDK, a chat widget, and three cookie banners — all to compute a slug.
- Tracking. Your input leaves the device the moment you paste it. The "tool" is really a form that POSTs to a server, where your data is logged, stored, and eventually sold.
- Layout shift (CLS). The ad slot is empty for 800 ms, then snaps in and shoves the result you were reading down by 250 px. Multiply by 3 ad slots per page.
- Slow server rendering. A tool that could be a static HTML file is instead a Node/PHP render that takes 600–1200 ms TTFB on a cold origin, because someone thought every URL needed to be "dynamic".
None of this is necessary. A unit converter does not need a database. A JSON formatter does not need a backend. The honest version of these tools is: one HTML file, one small script, runs in your tab, forgets everything when you close it. ToolHub is my attempt to build the honest version, at scale, across 169 tools.
2. Architecture Choice: Static Export + Lazy Service-Worker Caching
The whole site is a Next.js static export:
// next.config.ts
const nextConfig = {
output: 'export', // every route → static HTML on a CDN
images: { unoptimized: true },
trailingSlash: true,
reactStrictMode: true,
}That one line does most of the work. There is no origin server, no SSR cold start, no database to scale. Every tool page is a pre-rendered HTML file sitting on the edge. TTFB is whatever your CDN's is — typically under 50 ms.
The catch with a pure static site is that a returning visitor still re-downloads everything on a hard reload, and there's no offline story. So I added a hand-written Service Worker (public/sw.js) that does two jobs:
- HTML navigations → network-first. You always see the newest version when online; if the network dies, it falls back to the cached copy, then to a cached homepage as the offline shell.
- Static assets → stale-while-revalidate. The first return visit is instant (cache), and the SW refreshes the asset in the background. Because
_next/static/*files are content-hashed, this is safe to cache aggressively.
The SWR handler is a dozen lines:
// public/sw.js — stale-while-revalidate for assets
async function staleWhileRevalidate(request) {
const cache = await caches.open(RUNTIME_CACHE)
const cached = await cache.match(request)
const networkPromise = fetch(request)
.then((fresh) => {
if (fresh && fresh.ok && fresh.type === 'basic') {
cache.put(request, fresh.clone()).catch(() => {})
}
return fresh
})
.catch(() => cached)
// serve cache instantly, refresh in the background
return cached || networkPromise
}Two details that matter: (a) the SW explicitly does not cache cross-origin requests — AdSense and analytics traffic is passed straight through, so I never accidentally cache ad creatives or break impression counting; (b) the SW bumps a VERSION constant on every deploy and pairs it with skipWaiting() + old-cache eviction on activate, which sidesteps the browser's 24-hour sw.js max-age and gets new code to users in minutes. The registration side also calls registration.update() on every page load.
The result is "lazy" caching by design: only pages a visitor actually opens get cached. Across 169 tool pages, I do not pre-cache the long tail — that would balloon install time. People get offline access to the tools they use, which is the only offline access that means anything.
3. The pSEO Engine: 169+ JSON-LD Schemas & an Automated Internal-Link Mesh
Programmatic SEO (pSEO) gets a bad name because most of it is thin, templated junk. The version that actually works has two halves: real structured data per page, and a real internal-link graph so crawlers can find and trust all of it.
Every tool is described once in a single source of truth (lib/tools.ts) and then generates four JSON-LD blocks automatically:
WebApplication+SoftwareApplication— tells Google "this is a free, in-browser app" with anOfferat price 0 and the rightapplicationCategory(Finance / Developer / Health / …).BreadcrumbList— Home › Category › Tool, rendered both as a visible<nav>and as machine-readable schema.FAQPage— pulled from the same FAQ data file that renders the visible on-page FAQ, so the schema and the visible content can never drift apart (drift is what gets you a manual action).HowTo— the standard three steps (input → view → copy/export), with atotalTimeestimate.
That's 169 pages × 4 schemas = 676+ structured-data blocks, all generated from one config file. Adding a tool is literally three steps: drop a page.tsx, add one entry to tools.ts, mark published: true. Homepage, sitemap, breadcrumbs, related tools, and all four schemas update themselves.
The second half is the link mesh. Every tool page ends with a Related Tools grid computed by category (same category first, featured pinned, backfilled with site-wide populars to always fill the grid). The homepage groups all 169 tools by category, which builds clean topical silos. Net effect: crawlers reach every tool in ≤ 3 hops, and link equity flows from the high-traffic tools outward to the long tail. This is the boring 80% of pSEO — no AI content, just a real graph.
4. AdSense & Performance: Zero-CLS Placeholders, Sub-Second Loads
The single biggest performance killer on ad-supported utility sites is Cumulative Layout Shift from late-loading ads. The fix is dumb and absolute: reserve the space before the ad exists.
ToolHub renders an AdPlaceholder component on every tool page that always occupies a fixed min-height: 250px box, whether or not AdSense has filled it yet:
// components/AdPlaceholder.tsx (simplified)
<div
data-ad-placeholder={slot}
className="min-h-[250px] w-full rounded-xl
border border-dashed
bg-slate-100/50 dark:bg-slate-900/30"
>
<span>ADVERTISEMENT</span>
</div>When AdSense injects a creative, it renders inside the already-sized box. The page never moves. CLS from ads is effectively zero. This also happens to be what AdSense reviewers want to see, and it's a direct Core Web Vitals ranking signal — so a decision made for UX doubles as an SEO decision.
Combine that with the static-export + SWR story from section 2 and the load math gets boring in the best way:
- HTML is on a CDN, served in tens of milliseconds.
- JS is split per tool; the interactive chunk is small.
- Return visits are instant (SWR cache).
- Ads do not shift the layout, so the CWV "good" band is stable.
The realistic outcome is a sub-second first contentful paint on a warm cache and a clean Lighthouse pass on a cold one. I am not chasing a 100; I am chasing "the page is obviously fast to a human," which is a much lower bar and the only one a user actually notices.
5. Client-Side 4-Language i18n: Zero Route-Splitting, Graceful Fallbacks
Supporting multiple languages on a static export is usually a nightmare: either you duplicate every route into /zh/..., /es/... (which dilutes canonical SEO link equity and requires complex hreflang maps), or you rely on heavy SSR.
ToolHub takes a client-side dictionary approach:
- Canonical English URLs: The core HTML routes and SEO schema remain clean in English for high-RPM search indexing.
- Isolated Dictionary Files: Translations for 169+ tools are split into per-locale modules (
lib/i18n/tools.{zh,es,de}.ts), mapped by toolslug, with English as the base data inlib/tools.ts. - Automatic Fallback & Detection: The app auto-detects
navigator.languageon first visit. If a specific translation key is missing in German or Spanish, it gracefully falls back to English without crashing or leaving blank text.
This keeps bundle sizes minimal while offering a native-feeling experience for European and Asian traffic.
6. Micro-Interactions & Ambient Aurora Glow at 60 FPS
A fast tool doesn't have to look like a 1990s plain-text document. To make ToolHub feel "alive" without tanking Core Web Vitals, I built a lightweight animation layer using Framer Motion (spring physics) and CSS ambient glows:
- Ambient Aurora Glow: A CSS-only background mesh glow using
blur-[100px]and 8-second keyframe loops, creating a subtle breathing background with zero main-thread JS cost. - Spring Physics for Micro-Interactions: Custom rounded pill dropdowns, category chip sliders (
layoutIdsliding indicators), and card hover effects run strictly ontransformandopacityto avoid layout reflows (Reflow). - Reduced Motion Support: Full
@media (prefers-reduced-motion)compliance to disable heavy transforms for users who prefer static UI.
7. Key Takeaways & Open Metrics
If I had to compress the whole project into a few lines:
- Default to static. 95% of "tools" have no business being server-rendered.
output: 'export'deletes an entire class of latency and ops problems. - A Service Worker is a caching layer, not a framework. Under 200 lines of vanilla JS gave me offline support + SWR across 169 pages. No
workbox, no abstraction. - pSEO is a data model, not a content farm. One config file → 676+ structured-data blocks + an automatic internal-link mesh. The schema and the visible page share one source of truth, so they can never disagree.
- Reserve ad space always. Zero-CLS is not a polish task; it's a 1-component architectural decision that pays off in UX, CWV, and ad review.
- Privacy is an architecture, not a promise. "Your data never leaves the device" is a true statement here only because the tools literally have no backend to send it to.
Open metrics (as of this post)
I believe in build-in-public with real numbers, so here is the honest current state instead of a victory lap:
- Tools shipped: 169 live.
- Supported locales: 4 (English, Chinese, Spanish, German).
- Structured-data blocks: 676+ (4 per tool).
- Build target: fully static export; zero origin server.
If you want to poke at the toolbox, the featured tools below are a good starting point. If you build utility sites and want to compare notes — especially on pSEO at scale and keeping CWV clean with ads — that's the conversation I want to have.