How to 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, it's worth discussing the problem we’re here to solve and why this is an excellent solution for many use cases.
The Use Case
Imagine you’ve built an application and API in Go – that may be used 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 sends requests directly to endpoints on the same host, this middle-man server forwards them onto the backend API, where all of the logic is handled. In turn, this server sends the response back to the browser.
This may be what you want. It would be best to obfuscate your backend from the rest of the internet. If your API is already exposed, there is an exquisite and straightforward solution that avoids node dependencies and allows multiple services to run.
Prerequisites
To follow along with this guide, you’ll need:
- Go 1.18 installed
- Node 16 installed
- Your favorite code editor
Getting started
You can 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 going into much detail, we have a REST API implemented in api/
and a React app in ui/
.
Let's 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.
Now, in a separate shell window, let's start our React app.
1cd ui && npm install && npm start
Now, we’re running our React app in development mode, so go ahead and navigate to http://localhost:3000 and 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 to utilize a built-in development proxy server.
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 to push 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.
Related Content: Tracing Services Using OTel and Jaeger
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, let's 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
We’ve boiled our app to several static files by running an npm run build.
From the project directory:
1touch ui/ui.go
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
Let's 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 so that Gin can serve 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 tells 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))
Let's add this route in our API/start.go
file, which now looks like this:
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}
Let's 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 a React app running with no express server and no node dependencies. You can hand this off as an RPM or DEB package or make it available to Homebrew.
Related Content: Creating Homebrew Formulas with GoReleaser
The Refresh Problem
Ok, cool; we've got a single page being hosted. But let's say we want another page. Customers demand websites with multiple pages, so we must be agile and support this ridiculous request. So, let's add an About page and utilize React Router to navigate to it.
So in ui/
1npm install react-router-dom
Let's 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};
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, let's rebuild our app and start it again.
1cd ui && npm run build && cd .. && go build && ./embeddable-react
And when we navigate to it:
Great! The only problem is to hit Refresh.
This is unfortunate but not surprising. When we hit refresh, we told the server we were looking for the file at ui/build/about – which doesn’t exist. React Router manages the history state of the browser 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: To further explain 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 permanently server index.html
on our /
route. So, let's 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 some things here, including our newest struct, fallbackFileSystem
. We’ve implemented our Exists and Open methods, ensuring they always return to 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 and ensures our CSS and JavaScript static files are available. It will serve them accordingly when the browser requests.
Next, we say, “OK, we don't have that file, but we do have a nice index file.” This is the English translation of line 5 above.
Let's rebuild and try again.
1cd ui && npm run build && cd .. && go build && ./embeddable-react
After refreshing 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 is a reasonable proof of concept, some notable subtleties must be covered in depth.
Authentication – exposing an unauthenticated backend API to the broader 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 must have files to compile the code. You might notice that the //go:embed build directive is unhappy if there are no files to embed in the ui/build
. You’ll have to run an npm run build
for the Go program to compile or satisfy it with a single file mkdir ui/build && touch ui/build/index.html
.
Summary
We simplified development and deployment processes by embedding a static React application in our binary.
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 occasionally. The bulk of the work is still in the API routes, which is done by design in our current project.
We’ve found this a valuable 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
.