There's a category of web app that doesn't need React. Or Vue. Or Svelte. Or a build step. Or node_modules.
I'm talking about single-purpose developer tools: JSON formatters, color pickers, regex testers, cron builders. Tools that take input, transform it, and show output. No auth, no database, no API calls.
For these, vanilla JavaScript isn't just "good enough" — it's better. Faster load times, zero maintenance burden, and deployment is literally copying files to a static host.
Here's the architecture pattern I use, demonstrated with a real tool: CronMaker, a visual cron expression generator.
The Architecture
Every tool follows this structure:
my-tool/
├── index.html ← Structure + SEO
├── style.css ← Theming + responsive layout
└── app.js ← All logic in an IIFE
Three files. No package.json. No webpack.config.js. No .babelrc. No tsconfig.json. Nothing to update, nothing to break.
Why an IIFE?
All JavaScript goes inside an Immediately Invoked Function Expression:
(() => {
'use strict';
// Your entire app lives here.
// Nothing leaks to the global scope.
})();
This gives you module-like encapsulation without module bundlers. No globals, no namespace collisions, no window.myApp pollution.
Step 1: State Management (Without a Library)
Even a simple tool has state. CronMaker tracks which fields are set to what values, whether you're in 5-field or 6-field mode, and the current theme.
Here's the pattern:
// === State ===
let mode = 5; // 5-field or 6-field cron
let fieldStates = []; // Each field's type and value
// === Constants ===
const FIELDS = [
{ name: 'Minute', min: 0, max: 59 },
{ name: 'Hour', min: 0, max: 23 },
{ name: 'Day of Month', min: 1, max: 31 },
{ name: 'Month', min: 1, max: 12 },
{ name: 'Day of Week', min: 0, max: 6 },
];
State is just variables. When state changes, you call updateAll() to re-render. That's it. No reactive proxies, no stores, no reducers.
function updateAll() {
const expression = buildExpression();
cronInput.value = expression;
humanReadable.textContent = toHumanReadable(expression);
renderExecutions(expression);
}
When does this break down? When you have deeply nested state with many independent subscribers. For a single-purpose tool, that never happens.
Step 2: DOM — querySelector and Move On
const $ = s => document.querySelector(s);
const cronInput = $('#cron-input');
const humanReadable = $('#human-readable');
const copyBtn = $('#copy-btn');
const themeToggle = $('#theme-toggle');
const presetsGrid = $('#presets-grid');
const fieldGrid = $('#field-grid');
const execList = $('#executions-list');
Cache your DOM references at init time. Use them everywhere. The $ alias saves typing and makes the code scannable.
For dynamic content (lists, grids), use innerHTML with template literals:
function renderPresets() {
const presets = mode === 5 ? PRESETS_5 : PRESETS_6;
presetsGrid.innerHTML = presets.map(p => `
<button class="preset-btn" data-expr="${p.expr}">
<code>${p.expr}</code>
<span>${p.label}</span>
</button>
`).join('');
// Event delegation on the container
presetsGrid.onclick = e => {
const btn = e.target.closest('.preset-btn');
if (!btn) return;
applyExpression(btn.dataset.expr);
};
}
Event Delegation
Instead of attaching listeners to every button, attach one to the parent. This pattern handles dynamic content naturally — no need to rebind after re-rendering.
container.onclick = e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
switch (btn.dataset.action) {
case 'copy': copyToClipboard(); break;
case 'reset': resetAll(); break;
case 'share': shareURL(); break;
}
};
Step 3: The Core Logic (Parsing & Generation)
This is where your tool's actual value lives. For CronMaker, it's bidirectional: parse a cron expression into field states, and generate a cron expression from field states.
function buildExpression() {
return fieldStates.map(state => {
switch (state.type) {
case 'every': return '*';
case 'specific': return String(state.value);
case 'range': return `${state.from}-${state.to}`;
case 'step': return `*/${state.step}`;
default: return '*';
}
}).join(' ');
}
function parseExpression(expr) {
const parts = expr.trim().split(/\s+/);
return parts.map((part, i) => {
if (part === '*') return { type: 'every' };
if (part.startsWith('*/')) return { type: 'step', step: parseInt(part.slice(2)) };
if (part.includes('-')) return { type: 'range', from: parseInt(part), to: parseInt(part.split('-')[1]) };
return { type: 'specific', value: parseInt(part) };
});
}
Key principle: Keep parsing and generation as pure functions. They take input, return output, touch no DOM. This makes them easy to test (even manually in the console) and easy to reason about.
Step 4: Theme Switching (The Right Way)
Dark mode is expected in 2026. Here's the minimal implementation:
/* In your CSS */
:root {
--bg: #ffffff;
--text: #1a1a2e;
--surface: #f5f5f5;
--accent: #6366f1;
}
[data-theme="dark"] {
--bg: #0f172a;
--text: #f1f5f9;
--surface: #1e293b;
--accent: #818cf8;
}
function initTheme() {
const saved = localStorage.getItem('theme');
const preferred = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
document.documentElement.dataset.theme = saved || preferred;
}
function toggleTheme() {
const current = document.documentElement.dataset.theme;
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
}
Three things to note:
- CSS custom properties do all the heavy lifting. One attribute change repaints everything.
- Respect system preference as the default.
-
Persist choice in
localStorage.
Step 5: Copy to Clipboard (With Feedback)
Every developer tool needs a copy button. Here's the pattern that works everywhere:
async function copyToClipboard() {
const text = cronInput.value;
try {
await navigator.clipboard.writeText(text);
showFeedback('Copied!');
} catch {
// Fallback for older browsers or non-HTTPS
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showFeedback('Copied!');
}
}
function showFeedback(msg) {
copyFeedback.textContent = msg;
copyFeedback.classList.add('visible');
setTimeout(() => copyFeedback.classList.remove('visible'), 2000);
}
The navigator.clipboard API requires HTTPS or localhost. The fallback handles HTTP and older browsers.
Step 6: SEO for Free Tools
If nobody finds your tool, it doesn't matter how good it is. Minimal SEO checklist:
<head>
<title>CronMaker — Cron Expression Generator</title>
<meta name="description" content="Build and decode cron
expressions visually. Free, no signup.">
<link rel="canonical" href="https://your-url.github.io/cronmaker/">
<!-- Structured data tells Google "this is an app" -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "CronMaker",
"description": "Visual cron expression generator",
"url": "https://your-url.github.io/cronmaker/",
"applicationCategory": "DeveloperApplication",
"offers": { "@type": "Offer", "price": "0" }
}
</script>
</head>
And include a content section below the tool with relevant keywords — the "cheat sheet" pattern works perfectly for dev tools. Google indexes it, users find it useful, everyone wins.
Deployment: GitHub Pages
git init
git add .
git commit -m "Initial commit"
gh repo create my-tool --public --source=. --push
# Enable GitHub Pages in repo settings → main branch
Your tool is now live at https://username.github.io/my-tool/. Free hosting, free SSL, free CDN, zero config.
Total cost: $0/month. Forever.
The Complete Init Pattern
Here's how all the pieces connect:
(() => {
'use strict';
// 1. Constants
const FIELDS = [ /* ... */ ];
const PRESETS = [ /* ... */ ];
// 2. State
let fieldStates = [];
// 3. DOM refs
const $ = s => document.querySelector(s);
const input = $('#input');
const output = $('#output');
// 4. Pure logic functions
function parse(expr) { /* ... */ }
function generate(states) { /* ... */ }
function toHumanReadable(expr) { /* ... */ }
// 5. Render functions
function renderBuilder() { /* ... */ }
function renderOutput() { /* ... */ }
function updateAll() {
renderOutput();
renderBuilder();
}
// 6. Event handlers
function onInput() {
fieldStates = parse(input.value);
updateAll();
}
// 7. Init
function init() {
initTheme();
input.addEventListener('input', onInput);
copyBtn.addEventListener('click', copyToClipboard);
updateAll();
}
init();
})();
This pattern scales surprisingly well. CronMaker is ~450 lines of JS. JSONPretty is similar. RegexLab is under 300.
When NOT to Use This Pattern
Be honest about the limitations:
- Multi-page apps → You'll want a router, which means a framework
- Complex forms with validation → React Hook Form exists for a reason
- Real-time collaboration → You need WebSockets and state sync
- Apps with auth → You need a backend anyway
But for the vast universe of single-purpose tools? Three files, zero dependencies, deploy and forget.
Tools Built with This Pattern
I've built a collection of developer tools using exactly this architecture:
- SigCraft — Email signature generator
- CronMaker — Cron expression builder
- RegexLab — Regex tester
- JSONPretty — JSON formatter
- ColorCraft — Color palette generator
- GradientLab — CSS gradient builder
- PixConvert — Image converter
- Faviconify — Favicon generator
All open source. All zero dependencies. All under 500 lines of JS.
Building something with this pattern? I'd love to see it. Drop a link in the comments.
Top comments (0)