SvelteKit and Go

Posted on Nov 7, 2023

After playing around with various bits and pieces, I think I’ve found a workable development model for me. I had a look at htmx and I kinda like it. It comes closest to what I’m used to in terms of developing web applications. But that’s also the problem. I want to learn something new. As I mentioned before, that something is Svelte. Now there’s a new kit in town (see what I did there? 😂) that promises to provide a kind of application framework for Svelte.

But I’m still not very familiar with developing modern Javascript applications. I’m more at home in the backend, thus my love for Go. I recently watched a video series on Youtube though that explained SvelteKit in quite some detail. The videos are only a few minutes long and explain one particular concept each. At least, now I have some idea of how SvelteKit is supposed to work. I also watched a nice video by one of the maintainers of Svelte and SvelteKit, Geoff Rich, which I highly recommend – not least because he reminds me of Otis from the Netflix series Sex Education, and I like Otis. 😅

The Setup

Coming from a Go background, my goal is to have a single statically-linked binary that I can just run on a server behind Apache (or nginx or whatever floats your boat). But I didn’t know how to combine SvelteKit with a Go API server. Luckily, I wasn’t the first one to try this and there are a few blog posts online that explain it. I borrowed from several of them and also from the official documentation for SvelteKit’s adapter-static as this is what makes it all possible.

So here’s how I set it all up. Apologies that it’s very Linux-centric but that is what I use daily (and have been for more than 20 years).

Prerequisites

You’ll need a few obvious things to get it all working.

Just download the appropriate file for you system (go1.21.4.linux-amd64.tar.gz in my case) and extract it to the proper location (/usr/local/go on Linux). Further instructions can be found in the official docs.

I’ve found it easiest to extract the Node tarball to a temporary directory (or your home) and rsync the whole shebang to /usr/local like so:

$ tar xaf node-v21.1.0-linux-x64.tar.xz
$ cd node-v21.1.0-linux-x64
$ sudo chown -R root:root . # make sure the files in /usr/local are owned by root after rsync'ing
$ sudo rsync -a * /usr/local/

After you’ve done this once, you can keep it all up to date using a small package called n (what a name 😅). This is my process for updating it all:

$ sudo npm install -g n@latest # install/upgrade n itself
...
$ sudo n latest # you can also use stable instead of latest
...
$ sudo npm install -g npm@latest # upgrade npm because Node doesn't always ship the latest version
...

That’s it, you’re ready to go (pun intented).

Create a Go project

$ mkdir example-app
$ cd example-app
$ go mod init example-app
go: creating new go.mod: module example-app

There’s nothing special about this. Anyone who has ever created a Go project (at least from Go 1.12 onwards) will be familiar with this. It will create a go.mod file that should look something like this:

module example-app

go 1.21.4

Initialise the frontend SvelteKit project

# still in the example-app dir
$ npm create svelte@latest frontend
...

This will ask you a few questions about your desired project layout. I chose to use a Skeleton project with TypeScript and ESLint and Prettier enabled. Afterwards we will have to adjust a few things to get it all working.

First, we need to install the frontend dependencies:

$ cd frontend
$ npm install
...

This will download all the dependencies that Svelte and SvelteKit require and anything you chose to use (like TypeScript and linters etc.) when initialising the frontend. But that’s not enough. We need to install an adapter that will compile your frontend to static files that can be served from the Go server. There are several different types of adapter for different kinds of deployment of your Svelte app. We’ll use adapter-static.

$ npm install -D @sveltejs/adapter-static
...

Tweak the frontend

Now that we have all components in place, we need to tweak some settings to make it all work together. I borrowed heavily from this blog post. Be aware that it seems to have been written for an older version of SvelteKit. Some of the file paths have changed and some settings go in different places.

First, let’s switch to adapter-static from the default adapter-auto. Edit the file svelte.config.js and replace the line (should be the first) that imports adapter-auto so that it looks like this:

import adapter from '@sveltejs/adapter-static';
// ...

You can leave the rest untouched for now.

SvelteKit uses some special paths and filenames to structure the application. The most important directory is the src/routes directory. This is the place where most application code lives. Each directory represents a different page of your application here. You place your page code in a special file called +page.svelte in each of these directories. There’s already one at the top level of the routes directory.

Alongside these, another special file can be created, called +layout.ts (or +layout.js if you don’t use TypeScript). It’s a file for page options which we will use to instruct SvelteKit how to render our frontend so that it can be served from Go. Go ahead and create src/routes/+layout.ts (or .js) and put the following code in it:

export const prerender = true;
export const trailingSlash = "always";

As far as I understand it, the first option makes sure that pages are prerendered as much as possible (see docs). The second option makes our lives easier a little later when serving the frontend from our Go backend. And that’s it, basically, for setting up the frontend configuration.

The Go backend

$ cd .. # you should be back in example-app

Here we’ll create a very simple Go webserver that will serve our frontend and also have an API endpoint for the frontend to consume. I omitted most of the error handling, and you’d also need to make sure to use appropriate middleware for rate limiting, authentication and so on.

First, create a main.go file and add the following content:

package main

import (
    "encoding/json"
    "log"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    mux.Handle("/api/", apiVersion("1.0"))

    log.Fatal(http.ListenAndServe(":3785", mux))
}

func apiVersion(version string) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Add("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"version": version})
    })
}

This will create an HTTP server listening on port 3785 that returns the specified API version in a JSON object if you query http://localhost:3785/api/.

$ curl http://localhost:3785/api/
{"version":"1.0"}

Bundling the frontend code

So, how do we serve our frontend from a single Go binary? Go has supported embedding files into the binary since version 1.16 with the embed package. We’ll use that to include the generated frontend code. So let’s generate it first. The way you do this is by executing the following steps:

$ cd frontend
$ npm run build
...

This will place the generated code in the build directory of our frontend. You can actually change that directory (let’s say to output) by including two options in svelte.config.js like this:

// ...
    kit: {
        adapter: adapter({
            pages: "output",
            assets: "output"
        })
    }
// ...

If you change the directory, you’ll have to make sure to replace all occurrences of build in the handler function below. There are other options you can use (see the docs) but for our purposes we don’t need them, the defaults are just fine.

You might be wondering whether you need to generate the frontend code manually every time but don’t worry, we’ll use Go’s generate functionality for that. To do that, create a frontend.go inside the frontend directory and add the following code:

package frontend

import (
    "embed"
    "io/fs"
    "log"
    "net/http"
    "strings"
)

//go:generate npm install
//go:generate npm run build

//go:embed all:build
var files embed.FS

func Handler(path string) http.Handler {
    fsys, err := fs.Sub(files, "build")
    if err != nil {
        log.Fatal(err)
    }
    filesystem := http.FS(fsys)

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        r.URL.Path = strings.TrimPrefix(r.URL.Path, path)
        http.FileServer(filesystem).ServeHTTP(w, r)
    })
}

All right, let’s take that apart. The two //go:generate lines download the frontend’s dependencies and then build our frontend code when we call go generate. The //go:embed lines embeds all files from the build directory in the binary upon calling go build once we have wired up the frontend.Handler in our main Go code.

The Handler function takes a path and returns an http.Handler. We’ll see in a bit why that path argument is useful. Inside the function we first remove the build part from the embedded files, that is, we create an fs.FS that contains all the entries below build. That’s because build will not be part of the URLs called to access these files but it’s easier to embed the whole directory.

Next, we create an http.FileSystem from the embedded files. In our returned HandlerFunc we strip the path we passed to Handler from the request URL. That way we can “mount” the frontend at whatever path we like in our Go server.

Note: The original blog post contained some code in the returned HandlerFunc that checked if the requested file existed at all and, if not, appended .html to the URL. This was to account for the SvelteKit frontend code requesting, for example, /a and expecting /a.html to be served. But this case is exactly why we set the trailingSlash option earlier. I believe, the code to check for this is therefore unnecessary.

Wiring up the frontend

Now all that’s left to do is to include the frontend handler in our Go server. Let’s do that then and add a line to our main.go:

package main

import (
    "encoding/json"
    "example-app/frontend"
    "log"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    mux.Handle("/api/", apiVersion("1.0"))
    mux.Handle("/", frontend.Handler("/"))

    log.Fatal(http.ListenAndServe(":3785", mux))
}
// ...

We simply import the frontend and wire up the Handler at /. Here you can see why we passed a path to the handler. I prefer to have the frontend served at the base URL and the API at /api but we could just put the frontend under /frontend and strip that inside the handler.

Running it all

If we want to build and run our server we can just run these commands:

$ go generate ./...
$ go build
$ ./example-app

If you nagivate to http://localhost:3785/ now it should show you the welcome page from the SvelteKit skeleton project.

SvelteKit welcome page

You can now add pages to the frontend via the normal SvelteKit mechanism (directories inside src/routes) but that’s a topic for another day. There are also a few pitfalls with regards to what can be imported where in frontend code but I’ll leave that for later, too. The post is long enough already. 😉

I mainly wrote this article for myself as some kind of reference documentation but I hope it will be useful for other people, too. If you have anything to add or correct, you can find me on Mastodon. Thanks for reading.