Part 2 was the survey. This is the experiment — the one that made me care about this whole topic. And the first thing I have to do is correct a misconception I let myself carry for a while, including in an earlier draft of this very post: this was never really about search.
It's easy to look at a database running in a browser tab on a blog and conclude "oh, search." But search was the most visible surface of the idea, not the idea. What I was actually probing is a question I still think is interesting: can a static site generator — Zola, output to plain files on a free GitHub Page — produce a genuinely dynamic application, with an in-browser database as its state engine instead of a JavaScript framework and an SPA?
Normally "dynamic app" means the whole SPA apparatus: React or one of its cousins, a state-management library, a build pipeline, and usually a server somewhere holding the data. The bet here is that you can skip all of it — ship static files, boot a real database in the tab, let queries drive the dynamic parts — and still deploy for free off a dumb static host. The blog is just the cheapest possible place to test that bet.
This post is me, two years later, warming back up to that question and being honest about what I built, what worked, and what I never finished.
A caveat on provenance, since transparency about the AI in the loop is a running thread on this blog: I built the first version of this during an o1-era sprint, leaned hard on the model, and wrote it up as if it were more finished than it was. I'm revisiting it now with Claude Opus 4.8 — and the revisiting is the point.
Two different things that only overlap at "search"
Let me separate the two ideas cleanly, because conflating them is exactly the mistake I made:
- Client-side page search. A blog genuinely needs this — type a word, find the post — and it should run in the browser with no backend. It's a real need, and it's a commodity: Pagefind solves it in 50KB. This is not what the SurrealDB experiment was for.
- Dynamic application state, driven by a database in the tab. Render the parts of a page that depend on relationships and queries — tags, categories, related content, filtered views — by asking a database, client-side, instead of pre-rendering every permutation at build time or wiring up a state library to hold and mutate it all.
The blog only ever exercised the boring corner of #2 (its tag and category pages). But #2 is the part with a future, and it has nothing to do with search.
Why a frontend database instead of a state library
The frontend state problem is usually solved with a library: Redux, Zustand, MobX, a pile of signals — a bespoke store, reducers, selectors, and a lot of glue translating between your data's natural shape and the shapes your components want.
A database in the tab is a different proposition. You get one mental model — data plus a query language — instead of N hand-rolled stores. You get relationships for free: "posts in this category, newest first, with their tags" is a query, not a manually-maintained index. SurrealDB in particular is interesting here because it speaks document, relational, and graph in one engine, with live queries — a subscription that pushes you new results when the underlying data changes, which is exactly the reactivity a UI wants. The pitch, if it pans out: your app's state is a database, and your components are just views over queries.
That's the experiment — and the reason it's interesting on a static site specifically: if the database is your state engine, then a Zola build plus a static host gives you a dynamic app with no SPA framework, no bundler-of-doom, and no server bill. Whether that actually beats reaching for React-plus-a-state-library is genuinely unresolved. But it's a better question than "did I build search," and it's the one I want to come back to.
What I built: the data-delivery problem
The hard part of putting a database in the browser isn't the database — it's getting the data into the tab without dragging in a framework or a backend. Zola renders templates to HTML routes; it has no clean "emit a raw .sql file" mode. So I did the thing that worked: I made the dump a normal HTML route, /site.sql, with the entire SurrealQL payload inside a <pre> tag.
templates/site.sql.html (trimmed):
{% block content %}
<pre>
-- SurrealDB SQL Export, generated at build time
{% set section = get_section(path="posts/_index.md") %}
{% for page in section.pages %}
CREATE post CONTENT {
title: {{ page.title | json_encode() }},
path: {{ page.permalink | json_encode() }},
content: {{ page.content | json_encode() }},
date: {{ page.date | date(format="%Y-%m-%dT%H:%M:%SZ") | json_encode() }},
reading_time: {{ page.reading_time | default(value=0) }},
tags: {{ page.taxonomies.tags | json_encode() }},
categories: {{ page.taxonomies.categories | json_encode() }}
-- ...series, projects, summary, metadata...
};
{% endfor %}
</pre>
{% endblock %}
So the whole site serializes to database inserts at build time and ships as a plain file from a static host. That's the crux of the whole thing: a static site generator has no concept of a "database endpoint," but it can absolutely render one more file — so the database goes out as build output, free, off the same GitHub Page as everything else. It's a hack, but it's a hack that works with the grain of a static site generator instead of against it, and it's the actual answer to "how do you get application state onto the client with no server and no framework." That question is the real crux — solve it and the dynamic-app-from-a-static-site idea has legs.
The browser half: boot, seed, and a reactive seam
themes/consoler-dark/js/main.js boots an in-memory SurrealDB, recovers the SQL from that <pre>, and seeds the database — then announces itself:
import { Surreal } from "surrealdb";
import { surrealdbWasmEngines } from "@surrealdb/wasm";
const db = new Surreal({ engines: surrealdbWasmEngines() });
const html = await (await fetch('/site.sql')).text();
const sql = new DOMParser().parseFromString(html, 'text/html')
.querySelector('pre').textContent;
await db.connect("mem://");
await db.use({ namespace: "test", database: "test" });
await db.query(sql);
window.db = db;
window.dispatchEvent(new CustomEvent('DatabaseReady', { detail: { db }, bubbles: true }));
That DatabaseReady event is the interesting seam, and it's the app-state idea in miniature: the rest of the page doesn't import the database, it reacts to it. Pages subscribe; when the data is ready, they query and render. Swap db.query for a SurrealDB live query and that same seam becomes genuine reactive state — the UI re-renders when the data changes, with no store and no reducer. I didn't get that far. But you can see the shape from here.
Where it's wired in today
Two pages — /tags and /categories — render entirely from client-side SurrealQL. The whole dynamic behavior of the tags page:
<div class="feed" id="feed"></div>
<script>
window.addEventListener('DatabaseReady', ({ detail: { db } }) => {
db.query('SELECT * FROM post WHERE tags != [] ORDER BY date DESC')
.then((res) => renderFeed(res[0], document.getElementById('feed')));
});
</script>
renderFeed rebuilds the same feed-entry markup the static homepage renders server-side, so a database-driven page is visually identical to a Zola-rendered one. On a blog this is a modest demo — a filtered, sorted list. But it's the same machinery you'd use to drive a genuinely dynamic app view, and that's the part worth taking seriously.
The Vite gotchas (the genuinely transferable bit)
Packaging a WASM database for a static site is where the real engineering lived (vite.config.js):
export default defineConfig({
build: { outDir: 'static', target: 'es2020' }, // engine emits BigInt literals
optimizeDeps: { exclude: ['@surrealdb/wasm'] }, // or Vite mangles the .wasm binary
esbuild: { target: 'es2020', supported: { 'top-level-await': true } },
});
Three things had to line up: an es2020 target (the engine emits BigInt literals), top-level await (the WASM module initializes asynchronously at import), and — the non-obvious one — excluding @surrealdb/wasm from Vite's dependency pre-bundling, because the optimizer happily mangles the .wasm into something that won't instantiate. The dev workflow runs Zola and Vite in parallel so content and bundle rebuild together. None of that is in a quickstart; figuring it out is the part of this experiment I'd defend without hesitation.
What I planned vs. what shipped — honestly
There's an earlier post where I described a much grander version of this: a BM25 full-text index, a page table, a taxonomy graph, a live search box. Going back to it, most of that never shipped — there's no index, the real dump creates post records and a couple of vestigial tables, and the search box doesn't exist. That post documented a plan as if it were a result, which is a very specific failure mode of moving fast with an eager model: it will hand you a complete, confident, internally-consistent design for a system it has never run, and it's on me to notice which parts touched reality.
I'm flagging that not to flog the old work but because it's the honest backdrop: the experiment was earlier-stage than I let on, and coming back to it means being clear about where the edges actually are.
The real limitations (which are findings, not failures)
mem://means re-seed on every load. No persistence — the tab downloads the dump and rebuilds the database from scratch each visit. Fine for a few hundred KB of posts; the obvious next step is an OPFS or IndexedDB engine so the state survives navigation, which is table stakes if this is ever going to be real app-state.- The reactivity isn't wired up. I'm running one-shot
querycalls, not live queries, so today it's a database I read once, not a reactive store. That's the single most interesting thing left undone. - Payload. Shipping a WASM engine plus the serialized content is heavy for a blog. For an application where you'd ship a framework anyway, the trade looks different.
So where does this leave things?
Two clean takeaways, finally separated:
- The blog needs page search — and it should be the commodity, client-side kind. That's a small, solved problem I'll just ship, and it was never what this experiment was about.
- The frontend-database-as-app-state idea is worth continuing — for an actual application, with persistence and live queries doing the work a state library would otherwise do. That's a future post, and a more honest framing of what I was reaching for all along.
That second takeaway is the one this series is going to chase. The honest version of this experiment isn't "I added search to my blog" — it's "I proved to myself you can serve a working database off a static site, for free, and drive page state with it." The blog is the proof of concept. The next step is the actual point: build a real application this way — a static generator for the shell, a statically-served database for the state, OPFS persistence so it survives a refresh, and live queries doing the reactive work a store and a pile of reducers otherwise would. And if the pattern holds up, pull the boot-a-database-from-a-static-dump machinery out into a small library worth handing to other people — so this stops being a clever one-off and becomes something you can reach for.
That's where this series goes next: from "look, a database in a tab" to "here's a dynamic app with no SPA, on free static hosting — and here's the library that makes it repeatable." Whether "your state is a database, served statically" actually beats the JavaScript-heavy status quo is the only version of this question I find genuinely interesting — and the only way to answer it is to build the thing.
— Parker Jones, parkerjones.dev