How to serve Vue SPA from Go (Golang)

A step-by-step guide to building a Vue SPA with Go’s standard HTTP server

Photo by Chris Ried on Unsplash

Full source code can be found here.


  1. npm
  2. Vue CLI
  3. Go compile (1.16+ for embedding in this example, recommend 1.18+)
  4. (Optional) Docker

The very first thing is to set up our project. Let’s say our project name is go_spa_example. Here’s the code to do that:

mkdir go_spa_example
cd go_spa_example
# create Go module, I am using my Github here
go mod init

Then initialize our Vue frontend:

# To make it simple, I just name the Vue project ui
# flag -n means no git
vue create -n ui

Now, Vue CLI will ask you about the project options. The Vue Router (since we are building SPA) is the most important thing, so choose “Manually select features.” Here are my configurations:

preset: manually select features features needed: babel, router, linter Vue.js version: 3.x history mode: yes linter/formatter: basic additional lint features: lint on save prefer placing: in dedicated config files save as preset: n

Our project setup is done. Let’s handle the frontend first.

We are not going to create fancy frontend things here, just have a workable example with default Vue template pages that will contain the following pages:

  • Index page with path "/"
  • About page with path "/about"
  • Everything else will show the Not Found page

We need to do the following to complete this:

  1. Change the location of frontend build artifacts
  2. Add a Not Found page
  3. Setup alias for index page and catch all routes for the Not Found page

Change frontend artifacts location

If we build the frontend now, the output artifacts will be in ui/distlike this:

├── css
├── favicon.ico
├── index.html
└── js

The dist is now the root folder to serve the frontend, and it has each type of static files, eg, js, CSS, etc., under its own folder.

But this common layout is a little bit complicated to handle with Go’s standard HTTP library (I will explain more about this later).

Let’s aggregate all the static files into a centralized location.

Open ui/vue.config.js then add the attribute assetsDir with the value static like the following:

assetsDir in vue.config.js

The structure of artifacts now will be:

├── favicon.ico
├── index.html
└── static
├── css
└── js

All static files are under the static folder.

Based on this configuration, the URL for our index page will be:

And CSS file will look like this:

Create Not Found page

Create file ui/src/views/NotFound.vue with the following content:

Vue Not Found template

Now, we have a single file component (SFC) with a template only.

Add Index page alias and the catch all route

Finally, open up ui/src/router/index.jsthen:

  1. Add an alias alias: ['/index.htm'], to index page route (line 7 below).
    By default, Vue treat "/" and "/index.html" differently, so let’s make it more natural by adding this alias.
  2. Add a catch all route at the end of routes array (line 20 below).

Here’s the code to do that:

Catch all route in Vue Router

Excellent! We have completed our Vue frontend. Let’s embrace the most exciting part — the Go HTTP server.

This will be the main part of this story. However, to make things as simple as possible, I will define everything in a single main.go file.

Basically, what we need here is:

  • A Go embedding package
  • Go HTTP server
  • Mix/router
  • HTTP file server to serve frontend static files

I will also talk about some errors when serving SPA from Go.

Embedding our frontend artifacts

We don’t really need to embed our frontend file, but embed has its own potential usage, so I use it here as a practice.

(My project source code also has another branch, noembed, which uses a normal filesystem to serve the frontend. You can check it out here)

The advantage of using embed is that we can run our SPA anywhere without worrying about the location of frontend artifacts. However, we need to rebuild our program every time there’s a frontend change.

OK. Let’s create the Go embed package file, ui/ui.go:

Go embed package

make sure the file, ui.gois under the ui folder, not the project root. It lives with the other frontend files.

Note: We can put ui.go in the project root and change package to main in this example. But in a bigger project, we usually separate files into different packages, with each package serving its own purpose. The only purpose for ui.go is to serve frontend artifacts. So, I decided to put it in the ui folder, but it’s just my opinion.

This package declares a variable StaticFiles that serves as the embedding filesystem.

Also, pay attention to the Go direct //go:embed all:dist which means embedding all files in the dist folder and subfolders.

In Go version earlier than 1.18, there’s a big limitation in the embed, which cannot embed files prefixed with "." or "_". And some frontend bundle tools generate artifacts with that pattern.

Note: This issue has been fixed in Go 1.18 with the special term all: in the embed directive. But since Vue CLI didn’t generate artifacts with "." or "_" prefix, we don’t have to worry about this (if you are using an earlier Go version, please change the directive to //go:embed dist/*since all: is a 1.18 feature.

So, if you like to try other frontend libraries/frameworks, or if you are using an earlier Go version, you need to pay more attention to these.

A simple Go HTTP server

Now, create the file main.go in the project root folder to serve our HTTP server:

Go HTTP server main function

You might need to adjust the import path for the ui package.

It’s just a normal Go HTTP server (and I hardcoded the server port to 8888).

Again, to make the example as simple as possible, I put every route/handler in one function called router. Let’s talk about this.

Our mux/router

The router function will handle three kinds of routes:

  1. Frontend index page ("/" or index.html)
  2. Frontend static files (eg, JavaScript, CSS, etc.)
  3. APIs

Define the router function under main function like this:

Go HTTP router
  • On line 5, we handle the index page with the function indexHandler (I will work on this later)
  • On lines 8 to 10, we define a http.FileServer by creating a subfolder of our embedded filesystem. With that, we can search files starting with "static" instead of "dist" (By default, http.FileServer enables directory listings; you can check this great article to disable that behavior)
  • Line 13 is just a dummy API (anonymous function) for demo

Right now, you might wonder why we separate index handler ("/") from the static file server ("/static/"). Isn’t index.html just another file?

Suppose we only have the "/" route, which serves our file server if we go down the easiest path. We can complete this by doing the following:

  • Build and run the program
  • Type http://localhost:8888 in the address bar, and the index page will show correctly
  • Click the About link to show the right page with the correct URL (http://localhost:8888/about)

Everything looks good until you type http://localhost:8888/about directly in the address bar, then hit enter. Boom! Empty page with 404 page not found.

What’s going on?

Well, the problem is twofold.

First, Go searches the filesystem by URL path. For the case of http://localhost:8888/about. This is telling Go to search the about file in the file server. Since we don’t have such a file, the Go HTTP server returns a default 404 message.

Second, the about page only exists in the frontend. JavaScript did the frontend routing magic for us. Technically, JavaScript/CSS code does exist in backend, so we need a file server to serve these files.

The frontend also needs an entry point to start the SPA, much like Go’s main function. When the browser accesses the index page ("/"the entry point), it knows how to fetch other static files, start the Vue engine, and then Vue can route and render those pages for us.

That’s why we need to handle index route "/" Specifically.

There’s one final thing about Go’s HTTP route. Earlier, when we worked on Vue, we set assetsDir: 'static' in the vue.config.js, which is geared toward Go’s routing pattern rule. (If you are not familiar with Go’s routing rule, I highly recommend you to read this. One of the greatest Go books I’ve ever read!)

For Go, everything else not matched by other handlers will fallback to index route /. This is the effect we want, so we set it to our index page. When the user types an arbitrary URL in the address bar, it always falls back to index.htmlthen Vue can do the frontend routing correctly.

Since "/" index route has been used by indexHandler, we need to use another pattern for the file server. But if we are not setting assetsDir: 'static'we need to handle each static file type individually like this:

mux.Handle("/css/", httpFS)
mux.Handle("/js/", httpFS)

We have spent quite a huge page explaining these errors in Go. Let’s move on to our final piece of code, indexHandler.

Index page handler

Define indexHandler under router like this:

The code reads specific embedded files and then writes them to the HTTP response.

But it looks quite confusing because we also handle the favicon here.

Honestly speaking, I’m not quite familiar with the frontend. I didn’t find an easy way to change the location of the favicon file. The default location in Vue is under the root "/favicon.ico", so I handled it here directly. (We can handle it with a dedicated handler like mux.Handle("/favicon.ico", faviconHandler))

Now we have completed our Go SPA. Time to roll!

Build our frontend first, so there’s artifacts for Go to embed. Here’s code:

cd ui
npm run build

Run our Go HTTP server:

cd ..
go run main.go

Let’s make some tests. Open http://localhost:8888 (we hardcoded the port to 8888), and your browser should show default to the Vue home page:

Vue default home page

Click the top “About” link and change the about page:

Vue default about page

Type http://localhost:8888/about in the address bar, then hit enter. It should show the same about page.

Type an arbitrary URL like http://localhost:8888/login to show the not found page:

Vue not found page

Awesome! Everything looks great. Let’s use CURL to test our dummy API using this command:

curl -i "http://localhost:8888/api/v1/greeting"


HTTP/1.1 200 OK
Date: Fri, 20 May 2022 02:47:58 GMT
Content-Length: 13
Content-Type: text/plain; charset=utf-8
Hello, there!

Good, let’s test more. How about a nonexistent API, which could happen if a user typed the wrong address.

curl -i "http://localhost:8888/api/v1/greting"


HTTP/1.1 200 OK
Date: Fri, 20 May 2022 02:53:18 GMT
Content-Length: 611
Content-Type: text/html; charset=utf-8
<!doctype html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>ui</title><script defer="defer" src="/static/js/chunk-vendors.ea2100d5.js"></script><script defer="defer" src="/static/js/app.fc9d9338.js"></script><link href="/static/css/app.9bdcf330.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but ui doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>

Oops… How come the nonexistent API call worked?

If you look closely, the response body is actually index.html of our frontend.

The problem is, we don’t have any route that matches that URL, so it falls back to the index route "/".

Let’s add some guard clauses to prevent this:

Go HTTP index handler
  • On line 2, we add a method check to only allow the GET method
  • On line 8, if URL.Path is prefixed with /apiwe return the not found response

(Ideally, in a well-organized project, it’s better to put these conditions in things like middleware)

Let’s rebuild our program and try the nonexistent API again. It should return 404 this time:

HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Fri, 20 May 2022 03:14:42 GMT
Content-Length: 19
404 page not found

Congrats! We really have come a long way and built our full-fledged single-page application with Go successfully.

We can also build our application in Docker.

Create a dockerfile in our project root with the following content:

Go SPA dockerfile


# 1. build frontend
cd ui
npm run build
# 2. build docker image
cd ..
docker build -t go-spa:test .
# 3. (optional) remove intermediate images
docker image prune -f
# 4. run our SPA container
docker run --rm -p "8888:8888" go-spa:test

This is a long story, but it’s quite short and simple if you look at the full Go code. We need to understand the mechanisms and limitations of Go’s HTTP server and make some accommodations.

The principles are:

  1. Understand the structure of frontend build artifacts, find and adjust possible configuration settings, then serve these files in Go correctly.
  2. Understand the pattern-matching rules and quirks of Go’s HTTP handler

Before I started writing this story, I did spend lots of time figuring out how to make this work. I’m not an experienced programmer, especially in frontend, so please let me know if there are better solutions or if something is incorrect here.

Thanks a lot for reading!

Leave a Comment