Embed React in Golang


In this article, we’ll learn how to embed a React single page application (SPA) in our Go backend. If you’re itching to look at code you can get started with our implementation here or view the final source code in the embeddable-react-final repository.
In the meantime its worth discussing the problem that we’re here to solve and why this is a great solution for many use cases.
The Use Case
Imagine you’ve built an application and API in Go – maybe in use by a command line client or with REST. One day your project manager emerges from playing Elden Ring long enough to inform you that your customers demand a graphical user interface.
OK – no big deal you can write a simple React App to use your API… except your simple Web API which previously was deployed with a single binary now needs some dependencies. Some current React frameworks like NextJS or Gatsby are well supported but might be overkill and not as flexible as you like.
Typically, you deploy a front end application like this.

Where the browser is sending requests directly to end points on the same host. This middle-man server then forwards these requests onto the backend API where all of the logic is handled, and in turn sends that response back to the browser.
Maybe this is what you want. You might want to obfuscate your backend from the rest of the internet. In the case though that your API is already exposed there is a very elegant and simple solution, that avoids Node dependencies and running multiple services.
Prerequisites
To follow along with this guide you’ll need:
- Go 1.18 installed
- Node 16 installed
- Your favorite code editor
Getting started
Go ahead and clone the starting point for our app.
1git clone https://github.com/observIQ/embeddable-react.git
Here we have a To Do application. Unimaginative, yet still a cornerstone of web development tutorials.
Without getting into too much detail we have a REST API which is implemented in api/
and a React app in ui/
.
Lets start the API server. From the project directory:
1go mod tidy
2go run .
We can see we have a REST API listening on port 4000.
1[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
2
3[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
4 - using env: export GIN_MODE=release
5 - using code: gin.SetMode(gin.ReleaseMode)
6
7[GIN-debug] GET /api/todos --> github.com/observiq/embeddable-react/api.addRoutes.func1 (3 handlers)
8[GIN-debug] POST /api/todos --> github.com/observiq/embeddable-react/api.addRoutes.func2 (3 handlers)
9[GIN-debug] DELETE /api/todos/:id --> github.com/observiq/embeddable-react/api.addRoutes.func3 (3 handlers)
10[GIN-debug] PUT /api/todos/:id --> github.com/observiq/embeddable-react/api.addRoutes.func4 (3 handlers)
11[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
12Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
13[GIN-debug] Listening and serving HTTP on :4000
Now, in a separate shell window, lets start our React app.
1cd ui && npm install && npm start
Now we’re running our React app in development mode, go ahead and navigate to http://localhost:3000 and take a look at our React App. You should see some TODOs.\

And sure enough, our API got some hits:
1[GIN] 2022/03/31 - 16:51:51 | 200 | 192.723µs | 127.0.0.1 | GET "/api/todos"
You might be asking, “How did this even work?”. Good question!
Answer: Magic.
Well… at least create-react-app
magic.
Check out ui/package.json
line 5.
1{
2 "name": "todos",
3 "version": "0.1.0",
4 "private": true,
5 "proxy": "http://localhost:4000"
6 //...
7}
We used create-react-app
to bootstrap our ui
directory so we can uitlize a built in development proxy server.
You see when we run npm start
behind the scenes an express server is spun up, serving our html, javascript, and css. It also creates a websocket connection with our front end for pushing updates from the code when we save.
While this works great in development, this “proxy server” does not exist in a production environment. We’re responsible for serving the static files ourselves.
Embedding static files into our program
We need a way to serve a built React application from our Go API. To do this we can utilize the Go embed package to serve our file system.
First, lets make our production build. In ui/
run
1npm run build
We now have a build
folder with some files in it:
1└── ui
2 ├── src
3 ├── node_modules
4 ├── package-lock.json
5 ├── package.json
6 ├── public
7 └── build
8 ├── asset-manifest.json
9 ├── index.html
10 └── static
11 ├── css
12 │ ├── main.xxxxxxxx.css
13 │ └── main.xxxxxxxx.css.map
14 └── js
15 ├── main.xxxxxxxx.js
16 ├── main.xxxxxxxx.js.LICENSE.txt
17 └── main.xxxxxxxx.js.map
By running npm run build
we’ve boiled our app down to a couple of static files.
From the project directory:
1touch ui/ui.go
And copy and paste this code:
1package ui
2
3import (
4 "embed"
5 "fmt"
6 "io/fs"
7 "net/http"
8 "strings"
9
10 "github.com/gin-gonic/contrib/static"
11 "github.com/gin-gonic/gin"
12)
13
14//go:embed build
15var staticFS embed.FS
16
17// AddRoutes serves the static file system for the UI React App.
18func AddRoutes(router gin.IRouter) {
19 embeddedBuildFolder := newStaticFileSystem()
20 router.Use(static.Serve("/", embeddedBuildFolder))
21}
22
23// ----------------------------------------------------------------------
24// staticFileSystem serves files out of the embedded build folder
25
26type staticFileSystem struct {
27 http.FileSystem
28}
29
30var _ static.ServeFileSystem = (*staticFileSystem)(nil)
31
32func newStaticFileSystem() *staticFileSystem {
33 sub, err := fs.Sub(staticFS, "build")
34
35 if err != nil {
36 panic(err)
37 }
38
39 return &staticFileSystem{
40 FileSystem: http.FS(sub),
41 }
42}
43
44func (s *staticFileSystem) Exists(prefix string, path string) bool {
45 buildpath := fmt.Sprintf("build%s", path)
46
47 // support for folders
48 if strings.HasSuffix(path, "/") {
49 _, err := staticFS.ReadDir(strings.TrimSuffix(buildpath, "/"))
50 return err == nil
51 }
52
53 // support for files
54 f, err := staticFS.Open(buildpath)
55 if f != nil {
56 _ = f.Close()
57 }
58 return err == nil
59}
Now run:
1go mod tidy
Lets break this down a bit. Note lines 14 and 15.
1//go:embed build
2var staticFS embed.FS
This is utilizing the go:embed
directive to save the contents of the build
directory as a filesystem.
We now need to use this in a way that Gin can serve it as middleware so we create a struct staticFileSystem
that implements static.ServeFileSystem
. To do this we need to add the Exists method:
1func (s *staticFileSystem) Exists(prefix string, path string) bool {
2 buildpath := fmt.Sprintf("build%s", path)
3
4 // support for folders
5 if strings.HasSuffix(path, "/") {
6 _, err := staticFS.ReadDir(strings.TrimSuffix(buildpath, "/"))
7 return err == nil
8 }
9
10 // support for files
11 f, err := staticFS.Open(buildpath)
12 if f != nil {
13 _ = f.Close()
14 }
15 return err == nil
16}
This is essentially telling the server that, when the client requests build/index.html
, the file exists and needs to be served.
Now we can use it in Gin middleware, line 21:
1router.Use(static.Serve("/", embeddedBuildFolder))
Lets add this route in our api/start.go
file, which now looks like:
1package api
2
3import (
4 "github.com/gin-gonic/gin"
5
6 "github.com/observiq/embeddable-react/ui"
7)
8
9func Start() {
10 store := newStore()
11 router := gin.Default()
12
13 addRoutes(router, store)
14 ui.AddRoutes(router)
15
16 router.Run(":4000")
17}
Lets build the binary and see it in action. In the project root directory:
1go build
1./embeddable-react
We should see our server spin up. Now navigate to our backend server host localhost:4000 and voila!

We have React app running with no express server and no node dependencies. You can hand this off as an RPM or DEB package or even make available to Homebrew.
The Refresh Problem
Ok very cool, we got a single page being hosted. But lets say we want another page. Customers these days demand websites with multiple pages and we have to be agile and support this ridiculous request. So lets add an About page and utilize React Router to navigate to it.
So in ui/
1npm install react-router-dom
Lets add an About page. From the project directory:
1touch ui/src/components/About.jsx
Copy this into it.
1import React from "react";
2import { Link } from "react-router-dom";
3
4export const About = () => {
5 return (
6 <>
7 <h3>About</h3>
8 <p>This is it folks, this is why we do it.</p>
9 <p>
10 Todos is an app that helps you (yes you) finally get organized and get
11 your life together.
12 </p>
13
14 <Link className="nav-link" to={"/"}>
15 Return
16 </Link>
17 </>
18 );
19};
Now add a Link to it in our ui/src/components/Todos.jsx
file.
1import React, { useState } from "react";
2import { useCallback } from "react";
3import { useEffect } from "react";
4import { NewTodoInput } from "./NewTodoForm";
5import { Todo } from "./Todo";
6import { Link } from "react-router-dom";
7
8export const Todos = () => {
9 const [todos, setTodos] = useState([]);
10
11 const fetchTodos = useCallback(async () => {
12 const resp = await fetch("/api/todos");
13 const body = await resp.json();
14 const { todos } = body;
15
16 setTodos(todos);
17 }, [setTodos]);
18
19 useEffect(() => {
20 fetchTodos();
21 }, [fetchTodos]);
22
23 function onDeleteSuccess() {
24 fetchTodos();
25 }
26
27 function onCreateSuccess(newTodo) {
28 setTodos([...todos, newTodo]);
29 }
30
31 return (
32 <>
33 <h3>To Do:</h3>
34 <div className="todos">
35 {todos.map((todo) => (
36 <Todo key={todo.id} todo={todo} onDeleteSuccess={onDeleteSuccess} />
37 ))}
38 </div>
39 <NewTodoInput onCreateSuccess={onCreateSuccess} />
40 <Link to="/about" className="nav-link">
41 Learn more...
42 </Link>
43 </>
44 );
45};
And finally add these routes with React Router. Our ui/App.jsx
now looks like this:
1import { Todos } from "./components/Todos";
2import { About } from "./components/About";
3import { BrowserRouter, Routes, Route } from "react-router-dom";
4
5function App() {
6 return (
7 <BrowserRouter>
8 <Routes>
9 <Route
10 path="/"
11 element={
12 <div className="container">
13 <Todos />
14 </div>
15 }
16 />
17 <Route
18 path="/about"
19 element={
20 <div className="container">
21 <About />
22 </div>
23 }
24 />
25 </Routes>
26 </BrowserRouter>
27 );
28}
29
30export default App;
Now lets rebuild our app and start it again.
1cd ui && npm run build && cd .. && go build && ./embeddable-react

And when we navigate to it:

Great! Only problem is… hit Refresh.

This is unfortunate, but not surprising. Essentially, when we hit refresh, we told the server we’re looking for the file at ui/build/about
– which of course doesn’t exist. React Router manages the history state of the broswer to make it appear as if we’re navigating to new pages, but the HTML of our document is still index.html
. How do we get around this?
Bonus: For further explanation on this phenomenon check out Stijn de Witt’s answer to this stack overflow question. We should all be as thorough as Stijn.
Create a fallback filesystem
Essentially we want to always server index.html
on our /
route. So, lets add some stuff to ui/ui.go
.
1package ui
2
3import (
4 "embed"
5 "fmt"
6 "io/fs"
7 "net/http"
8 "strings"
9
10 "github.com/gin-gonic/contrib/static"
11 "github.com/gin-gonic/gin"
12)
13
14//go:embed build
15var staticFS embed.FS
16
17// AddRoutes serves the static file system for the UI React App.
18func AddRoutes(router gin.IRouter) {
19 embeddedBuildFolder := newStaticFileSystem()
20 fallbackFileSystem := newFallbackFileSystem(embeddedBuildFolder)
21 router.Use(static.Serve("/", embeddedBuildFolder))
22 router.Use(static.Serve("/", fallbackFileSystem))
23}
24
25// ----------------------------------------------------------------------
26// staticFileSystem serves files out of the embedded build folder
27
28type staticFileSystem struct {
29 http.FileSystem
30}
31
32var _ static.ServeFileSystem = (*staticFileSystem)(nil)
33
34func newStaticFileSystem() *staticFileSystem {
35 sub, err := fs.Sub(staticFS, "build")
36
37 if err != nil {
38 panic(err)
39 }
40
41 return &staticFileSystem{
42 FileSystem: http.FS(sub),
43 }
44}
45
46func (s *staticFileSystem) Exists(prefix string, path string) bool {
47 buildpath := fmt.Sprintf("build%s", path)
48
49 // support for folders
50 if strings.HasSuffix(path, "/") {
51 _, err := staticFS.ReadDir(strings.TrimSuffix(buildpath, "/"))
52 return err == nil
53 }
54
55 // support for files
56 f, err := staticFS.Open(buildpath)
57 if f != nil {
58 _ = f.Close()
59 }
60 return err == nil
61}
62
63// ----------------------------------------------------------------------
64// fallbackFileSystem wraps a staticFileSystem and always serves /index.html
65type fallbackFileSystem struct {
66 staticFileSystem *staticFileSystem
67}
68
69var _ static.ServeFileSystem = (*fallbackFileSystem)(nil)
70var _ http.FileSystem = (*fallbackFileSystem)(nil)
71
72func newFallbackFileSystem(staticFileSystem *staticFileSystem) *fallbackFileSystem {
73 return &fallbackFileSystem{
74 staticFileSystem: staticFileSystem,
75 }
76}
77
78func (f *fallbackFileSystem) Open(path string) (http.File, error) {
79 return f.staticFileSystem.Open("/index.html")
80}
81
82func (f *fallbackFileSystem) Exists(prefix string, path string) bool {
83 return true
84}
We’ve added a couple things here, Notably our newest struct fallbackFileSystem
. We’ve implemented our own methods for Exists
and Open
, making it so that it will always return index.html
.
Secondly we’ve added some more middleware in AddRoutes
:
1func AddRoutes(router gin.IRouter) {
2 embeddedBuildFolder := newStaticFileSystem()
3 fallbackFileSystem := newFallbackFileSystem(embeddedBuildFolder)
4 router.Use(static.Serve("/", embeddedBuildFolder))
5 router.Use(static.Serve("/", fallbackFileSystem))
6}
The order is important here, the first middleware checks to see if the file exists, making sure our CSS and JavaScript static files are available. It will serve them accordingly when the browswer requests.
Next we say “OK OK we dont have that file, but we do have a nice index file.” This is the english translation of line 5 above.
Lets rebuild and try again.
1cd ui && npm run build && cd .. && go build && ./embeddable-react
Now after refresh on /about
we see our About page in all its glory. A multi paged, single page React App embedded in a binary. Magic.
Caveats
While this app serves as a reasonable proof of concept, there are some notable subtleties not covered in depth.
Authentication – having an unauthenticated backend API exposed to the wider internet is as dangerous as it sounds.
Development workflow – While developing the UI you’ll need to run both the backend server (with go run .
) and the node development server (npm start
). There are some tools to help you do this in a single shell window, we use Concurrently.
The ui/build directory needs to have files for the code to compile – You might notice that the //go:embed build
directive is unhappy if there are no files to embed at ui/build
. This means you’ll have to run npm run build
for the Go program to compile or simply satisfy it with a single file mkdir ui/build && touch ui/build/index.html
.
Summary
Its 2022 and when we saw the chance to eliminate a microservice, we took it. By embedding a static React application in our binary we’ve simplified development and deployment processes.
It’s worth noting that this is not putting much of a strain on our backend service, it simply has to serve up some javascript and css files on occasion. The bulk of the work is still in the API routes which, in our current project, is by design.
We’ve found this to be a useful and elegant solution to hosting a React App with our Go-written backend.
Acknowledgments
My colleague Andy Keller came up with and developed the fallbackFileSytem
workaround.
We took inspiration from this issue in the gin repo to implement our staticFileSystem
.
