We are two friends building Pageonaut, a collection of 126 free browser tools (converters, calculators, PDF and image utilities, dev helpers). Early on we committed to one constraint: everything runs client-side. No file uploads, no accounts, no server-side processing.
That one decision shaped the whole architecture, and it broke things in ways I did not expect. Here are the lessons, including the one where our server filled up with 419 GB of cache and took the site down twice.
Why client-side only
Every time I needed a quick converter, the top search results wanted me to upload my file to their server, create an account, or pay to remove a watermark. For work that a browser can trivially do locally.
So the rule became: drop a file into one of our converters and it never leaves your device. You can watch the network tab while using it. This is great for privacy and trust, and it has a nice side effect: our server does almost nothing per user, so hosting stays cheap even if a tool gets popular.
The cost: some tools are genuinely harder to build. PDF manipulation in the browser (we use client-side libraries instead of a server queue), image processing on the main thread without freezing the UI, and no "just call an API" escape hatch. When a tool truly needs the network (say, fetching a URL you give it), the page says so explicitly.
Lesson 1: Unbounded URL params + ISR = a full disk
This is the expensive one.
We built shareable challenge pages: beat my score, try this color, that kind of thing. The URLs look like /tools/<slug>/challenge/<value>, where <value> is user-generated. With Next.js ISR, every unique URL that renders gets persisted to the filesystem cache.
You can see where this is going. The value space is infinite. Bots found the pattern and started enumerating it.
.next/server/app grew to 419 GB. The disk filled up, the site went down, and because we did not understand the root cause immediately, it happened a second time a few days later.
The fix was two lines of thinking:
- Any route with an unbounded parameter space gets
export const dynamic = "force-dynamic"so it is never written to the ISR cache. - Validate params early and 404 anything malformed before it does work.
The rule I carry forward: ISR is a cache keyed by URL, so before enabling it, ask "how many distinct URLs can exist here?" If the honest answer is "infinite", ISR is the wrong tool for that route.
Lesson 2: Your budget server cannot build your app
We self-host on a cheap European hosting box (Plesk, Passenger). It runs the app fine. It cannot build the app: next build on Next 16 with 126 static tool pages OOMs the machine every time.
The setup that works: GitHub Actions builds on every push, tars up the source plus .next, ships it over SSH, moves the old build to .next.old, restarts, then health-checks the live site. If the health check fails, the old build is still there for a one-command rollback.
Not glamorous, but the whole pipeline is one YAML file and deploys are git push. If you are on cheap hosting: build where the RAM is, ship artifacts, keep the previous build around.
Lesson 3: We shipped the Vercel logo as our favicon for weeks
Embarrassing but true. create-next-app scaffolds a default src/app/favicon.ico, and Next injects it as the first icon link automatically. We had set a proper SVG icon in our metadata and never thought about it again.
Google, however, prefers the .ico. So when our site started appearing in search results, it showed the Vercel triangle next to our name. Google's favicon cache is also slow to refresh, so the wrong icon outlived the fix by days.
Checklist that would have saved us: replace favicon.ico with a real multi-size ICO (Google wants at least 48x48), add an apple-icon, and remember that in the Next app directory, file conventions silently win over what you write in metadata.
Lesson 4: Next.js metadata does not deep-merge
Related gotcha that cost us clean link previews: if a page defines its own openGraph block, it replaces the layout's block entirely. It does not merge.
We had siteName and a default og:url in the root layout. Every page that set its own OG title silently dropped og:site_name, and pages that set no OG block inherited the layout's og:url, which pointed at the homepage. So sharing /tools produced a preview card for the wrong URL.
If you run a multi-page Next site, curl a few pages and actually read the meta tags. Ours looked correct in code review and wrong in production HTML.
Lesson 5: 126 OG images without making 126 images
One thing that worked beautifully: the opengraph-image.tsx file convention. One React component in the tool route renders a branded 1200x630 card with the tool's name, and every one of the 126 tool pages gets its own share image at build time. Same trick for category pages. Zero design tool involvement.
The honest business part
The site is ad-funded. No premium tier, no email capture, and the tools keep working if you decline the cookie banner. Consent-gating the ad script has a funny consequence: ad-network site verification crawlers never see the script, so you verify via ads.txt or a meta tag instead. Took me a while to figure out why verification kept failing.
What I would tell past me
- Treat every URL parameter as hostile input for your cache, not just your code.
- Build artifacts in CI, not on the box that serves traffic.
- Grep your production HTML for
og:andfavicononce per quarter. - Client-side-only is a feature users cannot see in a screenshot. You have to say it, everywhere, plainly.
The site is at pageonaut.com. If you try a tool and something feels off, or there is a tool you keep wishing existed, tell me in the comments. That feedback loop is the main reason we are at 126 tools instead of 20.
Top comments (0)