The Wasm Component Model and idiomatic codegen
Idiomatic code generation for Go using the Wasm Component Model. Compiling for different languages always has tradeoffs, which is why using standards helps everyone.
How to build a website with dynamic HTML and a modern UI using Tailwind CSS using only the Go standard library. Embrace the minimalist web server!
Dependencies pose a significant maintenance burden on software projects. Every package introduces risks by adding code outside your control, making them a common attack vector. Failing to stay up to date can force stressful upgrades when security patches are released. This is especially challenging in ecosystems like JavaScript, where breaking changes and dependency churn are common.
At Arcjet, we’re on a path to zero dependencies for our developer security SDK. Our goal for the 1.0.0 release (coming soon!) is that when you install Arcjet, you only have to trust us because the only code you bring into your project is our SDK.
I’ve also been thinking about how we can achieve this with our server-side Go code. Our API is built for low-latency high-throughput security decisions, so performance is crucial. While the Go ecosystem experiences less dependency churn than JavaScript, where keeping up to date has become a serious chore, minimizing dependencies remains a goal across our entire codebase.
Luckily, Go has an extensive standard library. Over the holidays I was inspired by several recent blog posts about using the standard library first before reaching for third-party modules. I decided to experiment with building a website using only the Go standard library, plus HTML and Tailwind CSS for styling.
In this post I’ll discuss how I built a minimalist web server using the Go standard library which dynamically generates HTML and CSS using Tailwind CSS. The only external dependency is the Tailwind CLI and optional use of Air for live reload, neither of which are in the production build artifacts.
How often do you come back to an old project only to find it won’t build because some key dependencies have changed? Or you want to build a new feature only to find out there have been breaking changes to the core dependencies which must be upgraded first? Or a major API you relied on has been deprecated and the migration path is incomplete?
Whether it’s a side project or a major application you work on, I bet every developer has experienced this. It’s frustrating because you then have to spend time on rebuilding, refactoring, and/or migrating to the “new” way of doing things.
Using third party libraries speeds up development because you don’t need to reinvent the wheel. However, they always introduce maintenance overhead and usually come without any guarantee of continued updates or backwards compatibility. Multiply this for dependency you include and you have a real maintenance burden.
The standard library for whichever programming language you’re using also might not have any such guarantees, but mature languages know that developers rely on them. There is an implied contract that things should rarely break.
But in Go, there is an explicit contract:
In Go 1 and the Future of Go Programs (from 2012) the Go team state
Go 1 defines two things: first, the specification of the language; and second, the specification of a set of core APIs, the "standard packages" of the Go library. The Go 1 release includes their implementation in the form of two compiler suites (gc and gccgo), and the core libraries themselves.
And in 2023 this was followed up by Backward Compatibility, Go 1.21, and Go 2:
when should we expect the Go 2 specification that breaks old Go 1 programs?
The answer is never. Go 2, in the sense of breaking with the past and no longer compiling old programs, is never going to happen. Go 2 in the sense of being the major revision of Go 1 we started toward in 2017 has already happened.
There will not be a Go 2 that breaks Go 1 programs. Instead, we are going to double down on compatibility, which is far more valuable than any possible break with the past. In fact, we believe that prioritizing compatibility was the most important design decision we made for Go 1.
By relying solely on the Go standard library, we can effectively guarantee long-term compatibility and minimal breakage.
Yes! Go 1.22 introduced some improvements to the built in web server to make defining routes a lot easier, negating many of the ergonomic benefits of frameworks like Gin (and others to choose from).
And if we combine this with the existing support for serving embedded static files, compiling dynamic HTML templates, structured logging, SQL drivers for common databases, runtime traces, and the ability to execute commands before the Go build step, we can easily build a single binary with no external dependencies ready to ship to production.
Of course you can swap things out later - using an ORM like Gorm or exporting telemetry to Otel, for example - but Go has everything we need to get started in the standard library.
Developers familiar with frameworks like Next.js or Remix are accustomed to automatic static asset management and filesystem-based route definitions. With our minimalist Go server this is more manual, but can be implemented in a way that feels idiomatic with the routing enhancements in Go 1.22.
We’ll follow the commonly used Go Project Layout by defining the routes and server in main.go which will load the route handlers from an internal/handlers
package and keep the web content and templates in a web/templates
directory.
For a basic website we want a static directory of assets like CSS and images (at web/static
), plus a favicon and robots.txt
hosted at the root. These are embedded in the Go binary. We also include a simple health check to indicate the server is running when it’s deployed.
The server will be containerized and shipped to a modern hosting platform like Railway, Fly.io, Render, or one of the larger cloud providers. It’s standard to route requests through a proxy or load balancer which can handle SSL, so that’s another dependency avoided.
However, if you wanted to just host this on a single VM then you could use something like Certmagic to automatically generate a Let’s Encrypt certificate for you. It goes against our zero dependency philosophy, but dealing with issuing SSL certificates might not be something you want to write from scratch! This becomes more challenging when you have to sync certificates across multiple servers, which is why it's often delegated to the proxy frontend.
Finally, we also set up structured logging using log/slog
and use an environment variable to configure plain text (default, for development) and JSON (for production).
Assuming web/static/robots.txt
exists then when you go run main.go
and curl http://localhost:8080/robots.txt
you’ll get the contents of that file served. Same for robots.txt
and the health check.
Running code on every request is useful for logging, error handling, authentication, etc. Go web frameworks like Gin offer a rich middleware ecosystem, which is an advantage. Figuring out CORS is a pain! However, writing custom middleware for Go net/http
is straightforward.
To make this web server more robust, we’ll include a simple panic handler so that if any of the routes panic, we don’t crash the server.
Create a new package in internal/middleware
with middleware.go
defining the structure:
Then internal/middleware/recover.go
can be a new HTTP handler:
In main.go
we can wrap mux
with the new middleware and update the http.Server
to use the wrapped mux
as the Handler
Tailwind uses HTML class attributes to automatically generate the CSS needed to create the layout, but that means it needs a build step. It has to parse the HTML then build the CSS, which we want to ship with the Go binary so everything is self contained.
I followed Xe Iaso’s blog How to use Tailwind CSS in your Go programs to get this working. You have to set up a basic npm package in your root so that we can include the Tailwind CLI. The package.json build script line triggers the build where web/static/css/main.css
is any custom CSS you want included and web/static/css/styles.css
is the output. You can remove the main.css
file if you don’t have anything extra to add.
The tailwind.config.js
file looks like this:
Then in main.go
in the root we add a generate command so that we can trigger the npm build script as part of the Go build process.
Running go generate && go run main.go
triggers Tailwind to generate CSS in the web/static
directory, which Go then embeds into the build.
The final thing to do is set up a simple index page as our first route. Create an HTML file at web/templates/index.html
with anything you like. Then at web/templates.go
set up this file:
This allows us to use the templates as a Go module so they can be imported elsewhere in our code.
In a new package at internal/handlers/root.go
we can define a root handler:
This reads the package template and then parses the data. Our panic recovery middleware will ensure that any template compilation errors are handled gracefully. The template can include variables and other structures, such as the title we pass in.
By using Go's html/template we get injection-safety - the templates themselves are assumed to be safe, but the data injected is not, so Go handles it appropriately.
Finally, in main.go
we can set up the handler:
One nice feature of web frameworks like Next.js is the instant reload whenever you make code changes. To implement live reload for our Go web server we can launch it with Air.
After you have installed Air and generated the default config with air init
then you can adjust the build configuration with the following:
How many times have you come back to a side project only to forget how to actually run it? Make
tends to be installed by default on most systems so if we always write a make dev
command then we don’t need to remember anything!
Following the example of go-blueprint, I also set up a Makefile
to easily start the web server. Here’s the contents:
Running make dev
will launch air
watching files for any changes. If you edit a template or any of the Go files, the server will relaunch.
We can extend our minimalist philosophy to the production builds as well. I like the Distroless project from Google which provides bare-minimum container images without any operating system and without running as root. Wolfi is an alternative if you need more choice over what's installed, but still want to go with a minimalist approach.
The Dockerfile
looks like this:
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod ./
ENV CGO_ENABLED=0
RUN go mod download
COPY . .
RUN go build -o server main.go
# Copy the server binary into a distroless container
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /
CMD ["/server"]
USER nonroot
This assumes the CSS has already been generated with go generate
after which you can build the container with docker build -t website --load .
and run it with docker run -t website
We’ve now built a self-contained website using Tailwind CSS and dynamic HTML templates, ready to deploy as a tiny ~10 MB Docker image. Contrast this to a typical Node.js container, which could easily exceed hundreds of MB. Smaller image sizes isn’t a particularly important goal by itself, but it’s indicative of all the extra bloat you’re shipping.
Unfortunately, we still have to rely on the Tailwind CSS CLI. To illustrate how crazy things have become even with just that single external dependency, take a look inside the node_modules
directory and see how many packages it requires! Thankfully, the Tailwind CLI is available as a standalone binary so you could switch to that - it's not as easy to install with npm though.
However, the server itself has zero external dependencies and since the default go.mod
contains the toolchain version, even if we come back to this in 10 years it should still build and run without any changes!
Idiomatic code generation for Go using the Wasm Component Model. Compiling for different languages always has tradeoffs, which is why using standards helps everyone.
Using Go + Gin to reimplement our backend REST API. How we built the golden API: performance & scalability, comprehensive docs, security, authentication, and testability.
Lessons learned from running production Go services that call Wasm using Wazero: embedding Wasm binaries, optimizing startup times, pre-initialization, wasm-opt optimizations, and profiling.
Get the full posts by email every week.