I've been building a set of browser-based developer utilities — JSON formatting, diffing, hashing, image and PDF work, that sort of thing. 114 of them now, all on one Next.js static export.
Two problems took far longer to understand than to fix. Both are the kind that don't announce themselves.
1. Every page shipped every tool's code
The site has a registry: one module listing every tool, its metadata, and its React component. The header imports it to build the search palette. That seemed harmless.
It wasn't. Here's the rule I didn't know:
In the App Router, a server module that references a
'use client'module — statically, throughnext/dynamic, or through a lazyimport()thunk — has that module's chunk attached to every route that imports it.
next/dynamic felt like it should defer the cost. It doesn't. It defers execution, not chunk attachment. And because the registry was imported by the layout's header, it was imported by every page — so every page carried every tool's JavaScript, including the PDF library, the OCR engine, and the image processing code.
A visitor opening a JSON formatter was downloading a barcode scanner.
The fix was a split by concern:
- the registry holds data only — slugs, names, categories, keywords
- the UI map lives in its own client module
- the prose map is server-only
Nothing clever, but you have to know the rule first. If you want to check your own: ANALYZE=1 next build emits source maps you can inspect.
The tell, before I understood the cause, was that every page had suspiciously similar JS weight regardless of what was on it. If your pages all cost the same, something shared is pulling everything in.
2. A 26.5 MB file I never load broke the entire deploy
The deploy started failing with Asset too large. The site is static files on Cloudflare, which has a 25 MiB hard limit per asset — and exceeding it fails the whole deploy, not just the page that needs the file. Nothing else ships.
The culprit was a WebAssembly build I never use.
I use ONNX Runtime Web for one tool, pinned to the plain WASM backend. But the package also ships a JSEP build — the WebGPU/WebNN variant — and it contains its own new URL(..., import.meta.url) reference. The bundler traced that reference and emitted a copy of the 26.5 MB file into the output.
Nothing in my source mentions it. No page requests it. The runtime is explicitly pinned:
ort.env.wasm.wasmPaths = '/wasm/ort/'
ort.env.wasm.executionProviders = ['wasm']
It was emitted purely because a file inside a dependency referenced itself in a way the bundler follows.
The fix was an ignore file listing that one asset so it isn't uploaded — kept deliberately narrow, because the other ONNX wasm file in the same folder is the one the tool actually needs. A broad pattern there silently breaks the feature.
Cloudflare also caps at 20,000 files. Worth knowing before you generate a page per item.
What I'd tell someone starting this
Bundlers follow references inside your dependencies, not just yours. A file you never import can end up in your output because something in node_modules pointed at itself.
Static-host limits fail loudly but late. Per-asset size and file count are worth checking on day one, not on deploy day.
"Lazy" in a bundler usually means deferred execution, not deferred download. Verify which one you're getting.
Make the build check itself. Mine now runs 3,877 assertions over the output directory before deploying, and refuses to ship if any fail. The tool logic has 3,175 tests of its own. Most of both exist because of a specific thing that broke once.
The tools are at 99tools.dev if they're useful to you. Everything runs in the browser — nothing you paste is uploaded, and they keep working with the network disconnected. Two exceptions, badged in the UI: the DNS lookup and the IP viewer genuinely need a server. Analytics is the cookieless kind, and there's nothing to sign up for.
Happy to answer questions about either bug.
Top comments (0)