When I started RandTap, I had a list of about eight tools I wanted to build. A dice roller. A coin flip. A password generator. A random number generator. A few others. Each one was a single-purpose utility I'd looked for separately on the web and never found a clean version of.
Eight became twelve. Twelve became eighteen. By the time I shipped, the app had 24 tools, with more queued. And along the way, the architecture question stopped being "how do I build a dice roller" and started being "how do I build a system that can hold 24 unrelated tools without collapsing into spaghetti."
This post is about what I learned. If you're building any kind of multi-tool app — a calculator collection, a converter suite, a generator hub — the patterns here might save you some time.
Why one app, not 24
The first decision was strategic, not technical. I could have shipped 24 separate single-purpose web pages. SEO-wise, that's probably better — each page can target its own keywords without competing.
But each tool was tiny. A dice roller is maybe 200 lines of code. A coin flip is 50. Building 24 separate sites would have meant 24 separate deployments, 24 sets of analytics, 24 navigation experiences for the user, and 24 places I'd need to update if I changed something cross-cutting like the theme or the sound system.
Bundling won. The trade-off was accepting that no single tool gets to optimize its URL structure perfectly. The win was a coherent product that compounds — every tool I add benefits from the shared shell.
The shell vs the tool
The first architectural decision that actually mattered was separating the shell from the tools.
The shell is everything that's the same regardless of which tool you're using:
- Navigation (sidebar, header, tool picker)
- Theme (dark/light mode, accent color)
- Settings (sound on/off, haptic feedback, history retention)
- Layout grid (tool content area, action buttons, result display)
- Cross-cutting features (copy result, share, history)
The tools are the thing-specific logic:
- A dice roller knows about face counts and rolls
- A password generator knows about character classes and length
- An animal facts tool knows about a database of facts
Once I was clear on this division, the architecture got dramatically simpler. Each tool became a self-contained module that exposed a small interface to the shell:
interface Tool {
id: string; // 'dice-roller'
name: string; // 'Dice Roller'
category: ToolCategory;
icon: IconName;
// The tool's main UI, rendered inside the shell
render: () => Component;
// The 'generate' action when the user taps the main button
generate: (state: ToolState) => Result;
// Format a result as text for clipboard / share / history
formatResult: (result: Result) => string;
}
That's it. Every tool implements that interface. The shell handles everything else.
Local state vs global state
The second pattern that mattered was being strict about state ownership.
A tool like the dice roller has its own state — what dice are selected, what the last roll was, the visual animation status. None of that needs to be visible outside the tool. Treating it as global state would have created cross-tool dependencies that don't actually exist in the user's mental model.
So tool state is local. Each tool manages its own state with whatever pattern makes sense for that tool — useState for simple ones, useReducer for complex ones, a small state machine for animation-heavy ones.
But there's a small slice of state that belongs to the shell and is visible everywhere:
- Active tool ID (which tool is currently displayed)
- Theme (dark/light, accent color)
- Settings (sound, haptics, history toggle)
- Recent results (the cross-tool history)
That's global. Tools can read it, but they can't write to most of it directly. They emit events ("a result was generated"), and the shell decides what to do with them.
This split kept the codebase navigable as it grew. When I added the 18th tool, I didn't have to think about what global state might conflict. I just wrote a self-contained module that conformed to the Tool interface and registered it.
Routing and lazy loading
With 24 tools, bundling everything into a single JavaScript file would have made the initial page load slow. Most users open RandTap to use one or two tools — they don't need the code for the other 22.
The fix was lazy loading. Each tool lives in its own module, and the shell loads them on demand:
const TOOL_REGISTRY: Record<string, () => Promise<{ default: Tool }>> = {
'dice-roller': () => import('./tools/dice-roller'),
'coin-flip': () => import('./tools/coin-flip'),
'password-gen': () => import('./tools/password-gen'),
// ... 21 more
};
async function loadTool(id: string): Promise<Tool> {
const loader = TOOL_REGISTRY[id];
if (!loader) throw new Error(`Unknown tool: ${id}`);
const module = await loader();
return module.default;
}
The first time a user opens a tool, there's a small loading state while the chunk downloads. After that, it's cached for the session.
For SEO, each tool also has a server-rendered shell with the tool's name and description in static HTML. Search engines see real content even before the JS runs. This was a cheap win that significantly improved indexing.
The web-to-iOS port
About six months in, I started thinking about an iOS version. The web app was working, but App Store distribution opens up a different audience and a different monetization model.
The naive plan was to wrap the web app in a WebView and ship it. This works, technically. But it doesn't feel like an iOS app. Tap latency is wrong. Animations don't feel native. Haptics don't trigger correctly. Users can tell.
The harder plan — and the one I went with — was to rewrite the UI shell natively, while keeping the tool logic largely shared.
The key insight was that the shell-vs-tool division I'd already made paid off here. Each tool's logic was already isolated and platform-independent. The shell — navigation, theme, settings — was the only part that had to be rewritten for iOS.
That's roughly 30% of the codebase, not 100%. A meaningful saving.
In practice, the iOS version uses a native shell and bridges into the tool logic through a thin adapter layer. The dice roller's "given current state, return the next roll" logic is functionally the same on both platforms.
Some adaptations were unavoidable:
- Animations are native on iOS (UIKit / CoreAnimation), not the same web CSS animations. They take similar parameters but produce different results.
- Haptics are a real iOS feature — iPhones have a Taptic Engine that's much richer than the limited browser vibration API. The iOS version uses much more nuanced haptic feedback than the web version can.
-
Persistence uses
UserDefaultson iOS,localStorageon web. The interface I exposed to tools is the same; the implementation differs underneath. - Offline behavior is different. The iOS version is fully offline by default; the web version assumes connectivity for some features (like sharing).
What I'd do differently
A few things, looking back:
Define the Tool interface earlier. I wrote the first six tools without a shared interface, then refactored them when the seventh tool revealed the pattern. The refactor was painful. If I'd seen the pattern from tool one, the codebase would be cleaner today.
Build the history feature later. I added cross-tool history early, before I knew what users actually wanted to do with it. Most of that code went unused. If I were starting now, I'd ship without history and add it only when users asked.
Treat the shell as a product. The shell is at least as important as any individual tool. It's what makes the whole experience coherent. I underinvested in it for the first version and paid for it later when I had to retrofit settings, theme, and accessibility properly.
Pick the tool boundaries by user intent, not technical convenience. Some of my early tools were split because they were technically separate modules in my head, even though users would have wanted them combined. (Example: I shipped a "random number" tool and a "random number range" tool as two tools. They should always have been one tool with a range toggle.)
What's there now
RandTap currently has 24+ tools across categories: dice, decisions, generators, randomizers, and a small "fun" category for things like animal facts. It's free to use on the web with no account, and the iOS version is on the App Store with full offline support.
You can play with the web version at randtap.com.
The architecture I described isn't anything novel. It's just the same separation-of-concerns pattern applied to a niche category that doesn't usually get architectural treatment. If you're building any kind of tool collection, the shell-vs-tool split, strict state boundaries, and lazy loading are probably worth applying from day one.
What multi-tool patterns have you found helpful? Drop a comment.
Top comments (0)