Build a SPA with Go and embedded SvelteKit

Nov 9, 2024

golangsveltekitsvelte

Motivation

The purpose of this article is to harness the power of Sveltekit for building dynamic frontend applications and serve it using Golang to deliver a single binary for our SPA.

The step-by-step

First, create a directory and initialize a Go project in it.

mkdir go-sveltekit-example
cd ./go-sveltekit-example
go mod init github.com/brequet/go-sveltekit-example

Building the frontend

Let’s focus on the SvelteKit application for now. In the project directory, instantiate a brand new SvelteKit project:

pnpx sv create frontend 
# Follow the installation steps and choose the configuration that suits you
cd ./frontend

Install the svelte static adapter and tweak your svelte.config.js file to make use of it:

pnpm i -D @sveltejs/adapter-static
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	preprocess: vitePreprocess(),

	kit: {
		adapter: adapter({
			pages: 'build',
			assets: 'build',
			precompress: false,
			strict: true,
			fallback: 'index.html',
		}),
	}
};

export default config;

Next add this file at ./src/routes/layout.ts

export const prerender = false;
export const csr = true;
export const ssr = false;

We’re almost there! Let’s add some static and dynamic routes to have something to test:

  • First, a static route by creating ./src/routes/about/+page.svelte:
This is the about page.
  • Next, a dynamic route with ./src/routes/[slug]/+page.svelte:
<script>
	import { page } from '$app/stores';
</script>

This route is dynamic: {$page.params.slug}.
  • Edit ./src/routes/+page.svelte:
<h1 class="text-3xl">Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>

<div class="flex flex-col">
	<a href="about" class="text-blue-500">About</a>
	<a href="dynamic-route" class="text-blue-500">Dynamic route</a>
</div>

From here, we can test if everything in our frontend works by building and previewing our app:

pnpm build
pnpm preview

Go to http://localhost:4173/, and see that all the pages are rendering smoothly:

The home page
The about page
The dynamic route

Serving the frontend using Go

Now to serve the built SvelteKit frontend using Go, there are a few things to keep in mind. The incoming request URL may represent:

  • one of our application route (e.g. /about), the web page will be responsible for handling redirection.
  • an asset (e.g. /favicon.png, or /_app/immutable/entry/start.CgSmxp2P.js) that we have to serve to the client.

Taking that into account, let’s create a file at ./frontend/frontend.go:

package frontend

import (
	"embed"
	"io/fs"
	"net/http"
	"path/filepath"
	"strings"
)

//go:embed build/**
var buildDir embed.FS

func Handler() http.Handler {
	stripped, err := fs.Sub(buildDir, "build")
	if err != nil {
		panic(err)
	}

	fileServer := http.FileServer(http.FS(stripped))

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "" || r.URL.Path == "/" {
			w.Header().Set("Content-Type", "text/html")
			index, err := fs.ReadFile(stripped, "index.html")
			if err != nil {
				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
				return
			}
			w.Write(index)
			return
		}

		// Serve assets from the build directory
		if strings.HasPrefix(r.URL.Path, "/_app/") || filepath.Ext(r.URL.Path) != "" {
			fileServer.ServeHTTP(w, r)
			return
		}

		// For all other paths, serve index.html to let SvelteKit handle routing
		w.Header().Set("Content-Type", "text/html")
		index, err := fs.ReadFile(stripped, "index.html")
		if err != nil {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}
		w.Write(index)
	})
}

We can now use this handler to serve our frontend, at the project root create a main.go:

package main

import (
	"log"
	"net/http"

	"github.com/brequet/go-sveltekit-example/frontend"
)

func main() {
	http.Handle("/", http.StripPrefix("/", frontend.Handler()))

	log.Println("Server starting on localhost:8080...")
	if err := http.ListenAndServe("localhost:8080", nil); err != nil {
		log.Fatal(err)
	}
}

And you are set! You can test it by running the Golang application and navigating to the URL.

From here, you can add your API endpoints to build your SPA, e.g.:


func main() {
	http.Handle("/api/hello", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello, world!"))
	}))

	http.Handle("/", http.StripPrefix("/", frontend.Handler()))

	log.Println("Server starting on localhost:8080...")
	if err := http.ListenAndServe("localhost:8080", nil); err != nil {
		log.Fatal(err)
	}
}

Serve the frontend using a base path

In some cases, you will want to serve your frontend with a base path, like https://my-website/app/... This requires tweaking a few things, let’s see how to achieve this. Edit your svelte.config.js to add this field in the kit object:

		paths: {
			base: '/app'
		},

Warning: do not forget to update any hard-coded paths or redirects in your frontend!

Edit your frontend handler at ./src/frontend.go:

...
	path := strings.TrimPrefix(r.URL.Path, "/app")
	
	if path == "" || path == "/" {
		w.Header().Set("Content-Type", "text/html")
		index, err := fs.ReadFile(stripped, "index.html")
		if err != nil {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}
		w.Write(index)
		return
	}
	
	// Serve assets from the build directory
	if strings.HasPrefix(path, "/_app/") || filepath.Ext(path) != "" {
		r.URL.Path = path
		fileServer.ServeHTTP(w, r)
		return
	}
...

And finally, adapt your server in the main file:

	http.Handle("/", http.RedirectHandler("/app", http.StatusMovedPermanently))
	http.Handle("/app/", http.StripPrefix("/app", frontend.Handler()))

And this is it, you’ve got an embedded SvelteKit application with Go!

The home page, served by Go, with a base path

What’s next

You’ve learned how to ship a simple Go binary embedding a SvelteKit application, using Go stdlib only. This example is meant to be quite direct and minimalist, it is now up to you to refactor this code if you would like, add API endpoints to it and create a true SPA!

You can find the final code at https://github.com/brequet/go-sveltekit-example.