Gofs v2 changes how the frontend and the database layer of a gofs app are built. Pages are rendered with Go’s standard html/template; interactivity comes from small web components written with Lit and Declarative Shadow DOM; and database access is plain SQL turned into Go by sqlc. templ, htmx and gofs’s own CRUD code generation are gone. This post explains why.
What v1 got right
Gofs v1 made a bet that still holds: a Go server with no framework, the standard library’s router, a single routes file, handlers with no business logic, and a hybrid SQL-plus-JSON data model. None of that changes in v2. If you have a v1 app, your backend packages, your handlers’ shape and your database move across almost untouched.
What we learned
v1 rendered HTML with templ and added interactivity with htmx. Both are good tools, and we chose them because they let us write the frontend in Go. Three things became clear over a year of building with them.
First, templ was two things at once: a code generator in the build loop, and a runtime dependency in the binary. The generator had its own language, its own LSP, and a version that had to match the Go version. We had written a philosophy page about avoiding abstractions between developers and the platform, and then put a custom template language between developers and HTML.
The original justification for templ was that hand-authoring raw HTML and Go’s html/template blocks felt verbose and cumbersome. But with the rise of modern AI coding assistants, that friction disappeared. AI generates, refactors, and validates standard Go templates and semantic HTML effortlessly. You no longer need to buy into a specialized template compiler just to save keystrokes.
Second, “Go on the frontend” was never quite true. The browser runs HTML, CSS and JavaScript. htmx is JavaScript. Every app ended up with a little more JavaScript next to it, and that JavaScript had no home, no conventions and no tests. The honest version of our evergreen argument is not “Go everywhere”; it is “Go on the server, and the web platform in the browser”. Browsers have a backward-compatibility commitment at least as strong as Go’s. The way to inherit it is to build on what they provide.
Third, web standards caught up. Custom elements are universal, and Declarative Shadow DOM gives us true server-side rendering for encapsulated component trees without a JavaScript runtime.
The frontend in v2
A v2 page is a Go html/template. The server renders the whole page, every time, for every route. There is no client-side router and no application state in the browser.
Where a page needs interaction, it contains an island: a custom element, which is a browser standard, written with Lit. Lit is a library in the sense our Libraries vs. Frameworks post means: it adds reactive properties and an html template literal to the standard, and dictates nothing else. Its templates are plain JavaScript, so there is no compile step and no bundler. A page with no islands loads no JavaScript at all.
For encapsulated components, v2 embraces Declarative Shadow DOM (<template shadowrootmode="open">). The Go server outputs the component’s encapsulated shadow tree directly in HTML. The browser renders it instantly with zero layout shift and zero JavaScript required for initial paint. When Lit loads, it automatically adopts the server-rendered shadow root.
The server renders an island’s initial HTML, including a working form where there is one. The page works before JavaScript arrives. When the component loads, it reads its state from the HTML it was given and takes over.
Requests from a component go through Lit’s Task controller, which handles the pending, complete and error states of a fetch, aborts superseded requests, and nothing more. It replaces what htmx did for us, without replacing the parts of htmx that were quietly turning pages into an app.
We vendor Lit and Task into the app and serve them from the Go binary. There is no package.json in a gofs app. Our Dev Tools Are Not Dependencies post explains the rule we use to decide what is allowed in.
MDN’s engineering team arrived at the same architecture, for the same reasons, when they rebuilt the site that documents the web. Our Islands, Not Apps post looks at what they found.
The database in v2
v1 generated CRUD functions from annotated Go structs with gofs’s own templates. It worked for the first table and started to grow a query language the moment anyone needed a search. We have replaced it with sqlc.
With sqlc you write plain SQL, and it generates a typed Go function for each query, checked against your schema. Crucially, sqlc bundles zero runtime dependencies. The generated code imports only the Go standard library (database/sql) and is committed to your repository; sqlc is not needed to build or run the app. It is the rare abstraction that gets cheaper as the app grows, because every new query is just a new query, and it leaves your production binary completely immune to ORM version churn and supply chain vulnerabilities.
The hybrid data model survives. Objects are still stored as JSON blobs in Postgres. Fields that need to be searched, sorted or indexed are exposed as generated columns computed from the blob, which gives Postgres an index and gives sqlc a real column to type. Our Sql DB, NoSql Schema post still describes the model; this is simply how it meets a typed query layer.
What else changed
Tailwind is no longer the default. The template ships with plain modern CSS, and Tailwind is available through its standalone binary for teams that want it. Node.js is not required for anything.
The gofs CLI gains a vendor command, which pins and checksums the browser libraries, and a doctor command, which checks that a project still follows the conventions: no node_modules, no unexpected files in assets, components where the loader expects them.
Upgrading
v1 and v2 can coexist in one app. htmx and Lit do not interfere with each other, and html/template pages can sit next to templ pages behind the same router. Move a page at a time, starting with the one you touch most.