CONTENTS13 +
What the rebuilt Web Gallery of Art runs on (Go, PocketBase, SQLite, templ and htmx), and how a read-heavy catalogue ended up with such a small stack.
When you modernise a thirty-year-old website, you’re allowed to replace almost everything. That’s also the quickest way to turn a working cultural resource into an expensive technology demo, so when I started rebuilding the Web Gallery of Art, I began with what the site actually does and let that decide what it runs on. It ended up on Go, PocketBase with SQLite, templ, htmx and a modest amount of plain JavaScript.
What the site actually does
The Web Gallery of Art (WGA) is a catalogue, and almost all of its traffic is reading. There are roughly 60,000 artwork images, each with three thumbnail sizes; the archive was about 12 GB when I started and has grown a little as I’ve added optimised variants. The site gets around 723,000 requests a day, which is a fair amount, but the requests are simple: people browse, search, open artist and artwork pages, and move between collections. Nobody is doing thousands of concurrent writes. The study board and the itinerary creator are properly interactive, but they’re a small corner of the site.
So there’s no need for high write throughput, real-time collaboration, state spread across regions, heavy client-side state, or horizontal scaling from day one. What the site does need is to be cheap to run, readable by search engines and screen readers, understandable to a future maintainer, and free of more infrastructure and dependencies than it uses. None of that calls for React or Next.js, a separate frontend talking to a separate backend, or a Kubernetes cluster quietly contemplating its own importance. I left them out because they didn’t fit what the gallery does.
How the pieces fit
The browser talks to one application. PocketBase is the foundation and the database layer, on SQLite; Go code built on top of it serves the public site, runs the background jobs and holds everything specific to WGA; the image files sit in object storage. There’s nothing else the application needs in order to run. A request goes from the browser to Go and PocketBase, and from there to SQLite or the images, which means a new maintainer can follow it without drawing seven coloured boxes and a message queue first.
1flowchart LR2 B[Browser] --> A["Go on PocketBase<br/>pages, fragments, scheduled jobs"]3 A --> D[(SQLite)]4 A --> O[(Object storage<br/>image files)]
Why there’s no SPA
Most of what visitors do is follow links. The server returns a page and the browser shows it, and browsers have had a few decades to get good at exactly that. Turning WGA into a single-page application would move page building and routing into the browser without fixing anything for most of the site. The study board and dual-pane browsing, which shows two parts of the collection side by side, do behave more like applications, but plain JavaScript handles them, so the rest of the site can stay server-rendered.
And because the server returns HTML, rather than JSON for a client app to turn back into HTML, a lot of the usual frontend work simply isn’t there. Bots can read the pages, screen readers can navigate them, people can bookmark them and links behave like links. Deep links work because every page is a real URL; there’s no client-side router recreating them.
Server-rendered HTML with templ
Pages are rendered with templ, which gives Go typed HTML templates (whoever named a templating system templ was presumably amused). Layouts, pages, fragments and components are reused throughout, and a full page and the fragment that updates part of one come from the same components. That covers most of what people like about frontend component frameworks, such as reuse, composition and consistent structure, except the components run on the server. There’s no hydration step and no virtual DOM rebuilding a page the server already rendered. The browser gets a document and does its ancient, apparently unfashionable job of displaying it.
htmx and plain JavaScript
Server rendering doesn’t mean a full reload for every click. htmx is a small library that lets an ordinary link or form fetch one of those fragments and swap it into the page, and WGA uses it where that’s noticeably nicer: pagination, search, filters, forms, detail views and navigation. That’s enough for the site to feel current without the browser becoming a second runtime that re-implements the server.
It also means WGA mostly works without JavaScript. Switch it off and nearly everything still works. The exceptions are the genuinely client-side features, the study board and image magnification. JavaScript adds theme switching, bionic reading and smoother transitions on top, but the catalogue is still a website underneath, which is progressive enhancement in a (sadly) unusually literal sense.
With htmx doing the talking to the server, very little client-side logic is left, which makes a frontend framework hard to justify. Frameworks come with dependencies, conventions, build tooling and upgrades, and you start paying for those straight away, whether or not you use what they offer. WGA uses TypeScript for type safety and ships ordinary browser JavaScript. TypeScript is about 9.9% of the repository, test suite included. The browser receives roughly 655 KB of JavaScript in total, and more than half of that is dependencies like htmx, Viewer.js and Trix. For a site with this many features I can live with that, and it’s less than a lot of frameworks weigh before you’ve written a line of your own.
Will the plain JavaScript turn into a framework of its own eventually? Probably, at least a little. Write reusable code for long enough and someone will accuse you of inventing a framework. I try to keep the helpers small, obvious and documented. Web Components aren’t used much yet, but they’re available if something needs a firmer boundary. Dual-pane browsing is a likely candidate, since it’s desktop-only, outside the main SEO path, and interactive enough to deserve its own custom element, which the browser provides without installing anything.
HTML that means something
Being readable by search engines and screen readers was as much a reason for server rendering as anything above. The old WGA comes from a time when markup mostly described what things looked like. The rebuild uses elements for what they are (navigation, articles, figures, headings, forms) and adds Schema.org metadata for machine-readable context. Screen readers and search engines benefit, and so do the automated systems that increasingly read the web. Whatever those systems become, clearly labelled content beats making them guess what a pile of anonymous divs was supposed to mean.
PocketBase
I’ll admit I like PocketBase. Its minimalism appeals to me, and I’d been waiting for a serious project to use it on. That isn’t a reason to put it into production, so it’s just as well there were practical ones.
It gives a self-contained deployment most of what I’d otherwise have assembled myself: database access and SQLite management, migrations, an admin interface, APIs, file handling, authentication if WGA ever needs it, and the web server and runtime. The admin interface mattered most. Go still has nothing like the admin panels Django, Laravel or Rails developers take for granted, and I’d rather not spend part of my life writing yet another CRUD interface.
In WGA, PocketBase is the application framework itself, with nothing in front of it. Nearly all of the public pages and interactions are PocketBase extensions written in Go, and so are the scheduled jobs: generating the sitemap, sending postcards, cleaning up the database and other maintenance. PocketBase provides the base, and the application owns its own routes and behaviour.
SQLite, and what it costs
Developers get oddly emotional about SQLite. For WGA the reasoning is dull: reads dominate, and nothing needs distributed writes, sharding, or several nodes writing to the same relational state. PocketBase makes strong performance claims for its SQLite setup, and WGA is a good real-world, read-heavy test of them. That’s also why OpenTelemetry tracing went in early. If the database turns out to be the problem, I want to see it in the traces, and I want to have ruled out the application’s own inefficiencies before blaming SQLite. If it does come to that, there are PostgreSQL-compatible PocketBase forks to look at.
There are real trade-offs: no sharding, no conventional redundancy, and scaling out isn’t an easy fix for slow code. I don’t mind that last one. If the application wastes work, it should stop wasting it instead of spreading the waste across more machines. When a visitor gives up on a request, for example, the work for it should stop, rather than carry on because another instance could, in theory, soak up the cost. Whether all this holds up as traffic grows is something the measurements will show.
The data was the hard part
Choosing the stack was easy compared with migrating the content. The dataset grew over three decades with very few enforced conventions, and normalising it took several attempts, a lot of manual work and an impressive collection of exceptions. Before LLM-assisted development was practical, a lot of it looked like it would need costly manual processing. LLMs did change that, mostly without touching the data: they helped me write the deterministic parser that does the processing. The migration is still plain, reproducible code, and the same source material always gives the same output, which is what you want with cultural and historical data. Models touch the dataset in one place only, to fill accessibility gaps. A screen reader needs a description of every image, and with more than 52,000 artworks and nobody available to write them all by hand, a model writes those descriptions in a separate step that keeps its output apart from the curators’ text.
Some things shouldn’t be normalised away. Modern software assumes people have a first and a last name; history never got that schema. Albrecht Dürer fits it reasonably well, Leonardo da Vinci doesn’t, because “da Vinci” isn’t a surname, it says he was from Vinci. Squeezing historical names into modern structures makes the data tidier and less correct. So WGA normalises where it can and keeps room for the irregular cases, and uncertain dates keep their ca. or ~ instead of pretending the database removed the uncertainty.
Very little is machine-derived so far. Colour palettes are extracted from the images and kept out of the normal editorial admin, and anything expensive to compute is done ahead of time, never during a request. I’ve thought about tonal or semantic analysis with language or vision models, but I’m wary. WGA has paintings, sculpture, architecture and more, and a model trained around ordinary images won’t necessarily say anything useful about all of them.
Running it
The images aren’t in SQLite. That shouldn’t need saying, but put enough developers near a keyboard and anything becomes a debate. Sixty thousand originals plus thumbnails would make the database enormous, and bundling them with the application would make every deploy heavy and tie storage to it. PocketBase keeps them in the S3-compatible object storage of Railway, the platform the application is hosted on: the application holds the catalogue and which file belongs to which record, and the storage holds the files.
Deploying used to be a batch job a few times a year, partly because pushing large amounts of material over SSH took a long time, and copying gigabytes is still copying gigabytes even with rclone. The new application deploys on Railway whenever something lands on main. That’ll get more controlled before the first production release, but deployments are already reproducible, which is the part that matters. The original site still runs on Lighttpd, on servers run by the KFKI Research Institute for Particle and Nuclear Physics. Railway fits the project now with a reasonable amount of effort, but I don’t expect it to host WGA for decades, so the application shouldn’t depend on it more than it has to. PocketBase backs up daily to S3-compatible storage, and schema changes go through its migrations. I only move the schema forward and don’t maintain reversible migration chains; if data ever needs restoring, that’s what the backups are for.
Predicting the running cost is difficult, because the architecture is so different from the old one and so much of the current traffic is bots. My rough target is about €10 per 5,000 daily active users under realistic conditions; at today’s traffic, bots included, I expect €50 to €100 a month. Cost matters because WGA isn’t a commercial product and I’d rather not put ads in front of visitors. A small stack saves more than money, too. Every extra service is another thing that can fail, another upgrade, another account and credential, more documentation and more to understand, and in my experience the expensive parts and the fragile parts tend to be the same ones.
Sentry sponsors the project through its open-source programme, and it has already caught bugs during development that would have been hard to reproduce and might have gone unnoticed for a long time. OpenTelemetry covers the application side. That’s enough to see where time goes, which requests are expensive, and whether a failure came from my code, the storage, rendering or something else, without building an observability department for an art gallery.
The alternatives I didn’t pick
React or Next.js would have meant two applications to serve one website: a backend that fetches and prepares data, and a frontend that asks for it across a boundary and builds the pages again, each with its own build system, dependencies and upgrades. Even in one container, that boundary would still be there. It’s a good design for plenty of systems, just not this one.
SvelteKit was the tempting one, because I actually like Svelte. But a framework I enjoy would still move the complexity around without making WGA any simpler. What mattered was whether the project needed a frontend framework at all, and so far it hasn’t.
PostgreSQL would replace something that works with something that has a better reputation for scaling, which isn’t a good enough reason.
A traditional CMS would duplicate most of what PocketBase already does, with another big system to maintain.
Static HTML was genuinely attractive. The original WGA proved for thirty years how long static content can last, and it’s very cheap to serve. The problem is the study boards, itineraries and dual-pane browsing: doing those properly on top of generated pages would take a lot of client-side code and rewriting, until you had a static site with an application bolted awkwardly on top. Server-rendered HTML keeps most of the advantages of static pages and still has a runtime where it’s useful, and with some caching most of the cost goes away, without rebuilding the whole site to fix a comma.
Microservices aren’t justified by anything in WGA’s current size. PocketBase already separates the admin side from the public side, and the code is organised by area, so pieces could be split out later if they ever need to be. I’d be delighted if the gallery outgrew PocketBase one day, but I doubt it will.
Keeping it maintainable
For me, “boring technology” means proven, stable and capable; age has nothing to do with it. htmx is young next to the web itself, and PocketBase is still changing. What I care about is whether a tool reduces what you need to understand or just moves the complexity somewhere more fashionable. Dependencies cost more than hosting does: upgrades, breaking changes, tooling, specialist knowledge, abandoned packages, build systems, friction for contributors, and the bus factor. WGA is mostly a one-person project on the technical side, so that matters even more. Contributions are always welcome, and Go is already enough of a hurdle for some people; there’s no need to add five more ecosystems before anyone can change a button.
I’ve always written code on the assumption that the next developer is a crazy person with a hatchet who knows my address. It does wonders for readability: ordinary solutions over clever ones, established conventions, visible boundaries, documentation and predictable behaviour.
Nobody knows whether anyone will want to rebuild WGA in 2056. The way people find and read information is changing enough right now that betting on another thirty years of ordinary web browsing would be brave. What I want is to keep WGA useful for as long as possible and make sure its software isn’t what eventually kills it, without freezing today’s web until 2056. If a Go compiler still exists in twenty years and the dependencies can still be found, it should still build. And I plan to keep maintaining it.
How it’s going
The choice I’m most sure about is htmx plus plain JavaScript. Frontend bloat had become the part of modern web development I found hardest to defend, and dropping most of it has been a relief, without the site losing its interactive features.
The main friction came from PocketBase itself. A big architectural change around version 0.20 meant extra work. That wasn’t a surprise, since PocketBase has always been clear that it’s pre-1.0 and may break things, and if you choose younger infrastructure you don’t get to be indignant when the warning turns out to be true. So far it’s been worth it.
Would I pick the same stack again? For WGA, yes; there’s nothing I’d swap out just to swap it. I wouldn’t recommend it everywhere, though. For heavily administrative applications, big write workloads, multi-region deployments, anything that needs to scale out, or real-time collaborative software, I’d think again, because those are different problems.
WGA runs on PocketBase, htmx and plain JavaScript because they suit this particular site. It’s still common to pick the technology first and find the reasons afterwards. With WGA I tried to start from the problem, leave out what it didn’t need, and make the rest work properly.
CITE THIS POST
Referencing this in a paper or thesis? Here’s a BibTeX entry.
1@misc{galicz2026why,2 author = {Galicz, Mikl{\'o}s},3 title = {{Why the Web Gallery of Art Runs on PocketBase, HTMX and Vanilla JavaScript}},4 year = {2026},5 month = oct,6 howpublished = {\url{https://blackfyre.ninja/blog/why-the-web-gallery-of-art-runs-on-pocketbase-htmx-and-vanilla-javascript/}},7 url = {https://blackfyre.ninja/blog/why-the-web-gallery-of-art-runs-on-pocketbase-htmx-and-vanilla-javascript/},8 urldate = {2026-10-28},9 note = {Blog post, blackfyre.ninja}10}