DEV Community

ANMOL TALWAR
ANMOL TALWAR

Posted on

Building 270+ boring, browser-only tools — and the CSP that broke next dev

I build a lot of small tools. A PDF merger. A BMI calculator. An audio converter. Individually none of them is interesting, and that's sort of the point. The interesting part is the constraint I gave myself: the tools run entirely in the browser, nothing you feed them gets uploaded, and no server does the actual work. No sign-up, no accounts, no file storage.

That constraint is what makes a pile of otherwise unremarkable tools worth writing about. When a tool can't fall back on a server, a lot of ordinary decisions get sharper. This is the build story for Every Boring Tool — currently 270+ tools — and the two engineering problems I actually spent time on.

Why "boring" and why in-browser
The honest reason is trust and operations. A tool that processes your invoice or your video entirely on your own device means the file never leaves the tab — there's nothing to store, log, or leak. And a tool with no server component doesn't have a database to migrate, a queue to drain, or per-request compute doing your users' work. The processing is WebAssembly and plain JavaScript running client-side. That's a boring shape, which is exactly what I wanted for a project that's mostly hundreds of little single-purpose apps.

The architecture: one data file, everything else generated
The stack is deliberately unfashionable. Next.js 14 with React 18, App Router, and plain JavaScript — no TypeScript. Route files are .js, tool components are .jsx, config is next.config.mjs, and there's no tsconfig.json anywhere. There's also no app/api directory — the tools don't post to server endpoints to do their work.

The one design decision that made 270+ tools manageable: the entire catalog lives in a single source-of-truth file, lib/tools.js. Categories and tools are just data — an array of 20 categories, each with a tools array of { slug, name, description } objects. Everything else reads from that:

Pages generate themselves. The dynamic route app/[category]/[tool]/page.js uses generateStaticParams to walk every category and its tools and enumerate all the routes. It sets dynamicParams = false, so any slug outside that closed set is a real 404 rather than a runtime surprise.
The sitemap generates itself from the same loop over categories and tools.
The layout reads the same categories array to render footer links.
Adding a tool is: drop an entry in lib/tools.js, add one .jsx component, and give it a content entry. Even the tool count is derived, not hardcoded — lib/toolCount.js counts catalog tools that have real content and rounds down to a tens boundary, so the footer's "N0+" label can never overstate and never drift out of sync.

Each tool mounts through one shared component, ToolMount, which is where the client-only rule is enforced:

"use client";
const Tool = dynamic(() => import(./tools/${slug}.jsx), {
ssr: false,
loading: () => ,
});
The page (a server component) never imports a tool directly — it just hands ToolMount a slug. ssr: false means the tool's code lives only in a client chunk and hydrates in the browser. Until it does, the loading fallback is what's in the server HTML, so you get a skeleton instead of a blank flash. Hold onto that detail; it's the villain of the last section.

Deep-dive 1: doing real work with no server
The fun claim is "it runs in your browser." The work is making that true for things people assume need a backend.

Audio and video go through ffmpeg.wasm. The important choice here is the single-threaded core. The multi-threaded build is faster, but it needs SharedArrayBuffer, which means serving the site with COOP/COEP cross-origin-isolation headers — and those headers have a habit of breaking embeds, third-party scripts, and anything else on the page. The single-thread core needs none of that, so the ffmpeg tools can't affect the rest of the site. That trade — some speed for zero blast radius — was easy to make.

The ~32 MB core is self-hosted from the site's own /ffmpeg path, fetched into blob URLs, and loaded once as a shared singleton across every audio/video tool via a module-level promise. One detail I'm glad I got right: if that load rejects, the cached promise is nulled out, so a retry can actually succeed instead of the tools being permanently bricked by one bad network moment on a 32 MB download.

On top of that, the tools do genuine encoder work. The audio converter writeFiles the input into ffmpeg's virtual FS, execs a per-codec arg list (MP3 via libmp3lame, AAC, Vorbis, WAV as pcm_s16le, FLAC), and reads the result back into a Blob. The video compressor's target-size mode is the one I'm quietly proud of: it reads the clip's duration in-browser from a throwaway

The rest of the "real work" tools follow the same no-upload rule with lighter libraries:

PDFs are built with pdf-lib — the invoice generator creates a PDFDocument, embeds standard Helvetica, draws text and an optional embedded logo, and saves the bytes into a Blob. Because pdf-lib's standard fonts are WinAnsi-only, it ships its own text sanitizer (smart quotes and dashes folded down to ASCII) and a manual word-wrap that measures glyph widths with font.widthOfTextAtSize. A surprising amount of code sits behind "the PDF matches the preview."
Barcodes render to a with JsBarcode; a single export is just canvas.toDataURL, and bulk export builds a ZIP entirely client-side with jszip, adding each PNG as base64.
None of these touch a server. The file the user picked never leaves the tab.

Deep-dive 2: the strict CSP that silently broke dev mode
This is the war story, and it cost me an afternoon.

The site ships a strict, enforcing Content-Security-Policy on every route. The high-risk vectors are locked down — object-src 'none', base-uri 'self', frame-ancestors 'self', form-action 'self' — and, crucially, script-src does not include 'unsafe-eval'. It does include 'wasm-unsafe-eval', which is a different token: it permits WebAssembly compilation (needed for ffmpeg and the other wasm-backed tools) but does not permit JavaScript eval() or new Function().

Everything worked in production. Then I ran next dev to fix a small thing, opened a tool page… and it sat on the skeleton forever. No error toast. No crash. Just the loading fallback, permanently.

Here's the trap, and it's the interaction of two facts from earlier:

Every tool is an ssr: false client component. It must execute JavaScript in the browser to replace the skeleton with the real UI.
Next.js dev mode — HMR, React Fast Refresh, webpack's eval-based source maps — leans on eval(). A CSP without 'unsafe-eval' blocks exactly that, and the headers() config applies in dev too.
So under next dev, the client chunk can't execute, the tool never hydrates, and the page is stuck on the fallback that my own architecture put there. The CSP wasn't broken and the tools weren't broken — dev mode's eval just isn't allowed to run against the same policy production uses. A production next build && next start bundle doesn't need eval(), so it hydrates fine.

The lesson I actually internalized: if your security headers differ in effect between environments, your interactive QA has to happen against a production build. For this site, "does the tool hydrate" is not a question next dev can answer honestly.

What I'd tell someone building something similar
Make the catalog data, not code. One source of truth that pages, sitemap, and navigation all derive from turns hundreds of tools from a maintenance nightmare into an afternoon.
Prefer the boring core. The single-threaded ffmpeg build is slower, but skipping cross-origin isolation saved me a category of bugs I'd never fully close.
Fail soft, and let users retry. Null the cached load promise on failure; fall back to a CRF ladder when a probe fails. When the tool has no server, the user's device is the runtime, and it's less predictable than one.
QA against the artifact you ship. A strict CSP is worth it, but it will disagree with your dev server. Trust the production build.
The live site is everyboringtool.com, and the full source — every tool component, the catalog file, the CSP config — is on GitHub at github.com/anmtal/everyboringtool. It's plain JS all the way down. Boring, on purpose.

Top comments (0)