Building a Lightning-Fast Static Site With Zero Frameworks
BoltQuickTools has over a hundred pages, ten blog posts, half a dozen category indexes, multiple language layers, and a sitemap. It uses zero JavaScript frameworks. No React, no Vue, no Next, no Astro, no Gatsby, no SvelteKit. There is no build step that takes more than two seconds. This essay is the story of why I chose this stack, what I gave up, and what I would do differently if I started over.
The stack in one paragraph
nginx (in a Docker container) serves static HTML, CSS, and JavaScript files behind Cloudflare. The HTML files are mostly hand-written, with a Python script that generates the homepage, category indexes, sitemap, and blog index from a YAML manifest. There is no JavaScript bundler. There is no transpilation. There is no virtual DOM. Total dependency count for the build pipeline: Python 3 and PyYAML.
Why no framework
I have built things with React for years. I like React. I would use React again for an app where state is rich, where you have many components that share state, and where the user interacts intensely with one page over a long session. None of those properties apply to BoltQuickTools.
Each tool page is a single-purpose interaction with shallow state. A JSON formatter has two textareas and four buttons. A QR code generator has one input and one canvas. These do not benefit from component composition; they benefit from being a tiny self-contained HTML file that loads in 200 ms.
The framework cost is also higher than people remember. A minimal React app ships ~45 KB of runtime even before your code. A Next.js app brings the framework, the router, and a hydration layer, usually ~120 KB of JavaScript on first load. For tool pages that ship 60 KB total today, that is a 2x weight penalty paid on every page load for zero functional gain.
The single-HTML-file pattern
Every tool page is structured as one HTML file containing:
- The standard
<head>with meta tags, canonical, schema.org WebApplication, OG tags. - An external link to
/assets/style.css, which is shared across all pages. - A small inline
<style>block for tool-specific CSS (avoids a second network request). - The standard nav.
- The tool UI (50-200 lines of HTML).
- An inline
<script>block with the tool logic (50-300 lines of vanilla JS). - The standard footer.
- The shared cookie-consent script.
Total file size per tool: 20 to 60 KB. Gzipped, 8 to 20 KB. That is the entire payload. The first paint on a fresh visit is the time to download one HTML file plus the shared CSS, which is in the Cloudflare edge cache. Subsequent visits are even faster because the CSS is cached locally.
Templating without a framework
I do not write each page's nav and footer by hand for every tool. That would be a maintenance nightmare. Instead, a Python script (build_site.py) reads a YAML manifest of tools and generates the homepage tool grid, the category index pages, the sitemap, and the OG image metadata. The actual tool body is hand-written and not regenerated.
This split mirrors the real change frequency: navigation and category structure changes often, tool logic does not. Regenerating navigation is cheap. Regenerating tool logic is impossible (the script does not know how a hash generator works).
# Tool manifest snippet
tools:
- slug: json-formatter
name: JSON Formatter
category: code-tools
description: Format, beautify, minify, validate JSON
icon: "{ }"
keywords: [json, format, validate, beautify, minify]
Total build time: under 2 seconds for the whole site. No watcher, no hot reload, no incremental rebuilds. python3 build_site.py && docker compose up -d web is the entire deploy pipeline.
CSS without a preprocessor
There is one style.css file, ~500 lines, using CSS custom properties for theming. No Sass, no PostCSS, no Tailwind. The trade-offs:
What I gave up: Tailwind's utility classes. Component-scoped styles. The ergonomics of $variable nesting from Sass.
What I gained: No build dependency. The CSS file I write is the CSS file the browser receives. No source maps, no debug-vs-prod confusion. CSS custom properties give me 90% of the variable ergonomics for none of the build cost.
JavaScript without a bundler
Each tool ships its own inline JavaScript, scoped to the tool page. Shared utilities (cookie consent, language switcher, ad-slot collapser) live in /assets/*.js files and are loaded via plain <script> tags. There are no modules, no imports, no exports. ES2020 syntax used directly.
If two tools share a utility (say, a download-as-file helper), I either inline it in both or refactor it into a shared file. The decision is made per-helper. Some live in three files because the cost of a shared file (extra HTTP request) is higher than the cost of 40 lines of duplication.
Where this falls down
The pattern is not universal. Three failure modes I have hit:
1. Cross-page state
The "recently used tools" feature on the homepage needs to know which tools you used. I keep it in localStorage and read it on the homepage. Works fine. But if I wanted "recently used tools across devices," I would need a backend and an account system, which destroys the privacy model. So I do not build that feature.
2. Complex shared UI
If I had a complex form that appears on twenty pages, copy-pasting the markup would become a real maintenance burden. So far, the only "shared component" is the nav and footer, which the Python script handles. Anything more complex would push me toward a templating engine. I have not hit that limit.
3. Interactive state machines
The randomizer tools have a small state machine (idle, rolling, locked, undone). Implementing it in vanilla JS with explicit state is fine for one state machine. If I had ten tools that all needed similar interaction, I would consider refactoring to a small shared state library. So far, only the gaming randomizers fit that pattern, and they share a randomizer.js helper.
The Lighthouse scorecard
The tool pages typically score Performance 95+, Accessibility 95+, Best Practices 92+, SEO 100. The Performance ceiling is set by the AdSense scripts, which I have no control over. Without ads, the same pages score 99 to 100 on Performance.
This is the practical demonstration: there is no need to ship megabytes of framework code to get a fast site. Static HTML, hand-written JS, and one CSS file is enough.
What I would change
If I started over knowing what I know now:
- Use a tiny templating library (Jinja2 specifically) from day one rather than starting with hand-written HTML and refactoring later.
- Set up
build_site.pyto also lint HTML and check for broken internal links. I have caught a few stale references manually that the script should have flagged. - Move the inline tool-specific CSS into
tool-overrides.csssections in style.css. Inline styles work but make CSP harder. - Start with Funding Choices CMP integration on day one rather than a homegrown consent banner.
- Build the i18n layer (currently a Google Translate widget) as a static-generated translation map from the start.
What I would NOT change
The no-framework decision. The static-file architecture. The Python build script. The single-HTML-file-per-tool pattern. These are the choices that make the site fast, cheap, and maintainable by one person.
If you are building a personal tool site, a portfolio, a documentation site, or anything with shallow per-page interaction, please consider not reaching for a framework. The web platform is more capable than the framework discourse implies. You can build a fast, privacy-respecting, search-indexable site with the same set of tools we had in 2015, and the result will be smaller, faster, and easier to maintain than the equivalent built with the current framework du jour.