Technical “How-To’s”

Embed React in Golang

Dave Vanlaningham
Dave Vanlaningham

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.


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

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.
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)
7[GIN-debug] GET    /api/todos                --> (3 handlers)
8[GIN-debug] POST   /api/todos                --> (3 handlers)
9[GIN-debug] DELETE /api/todos/:id            --> (3 handlers)
10[GIN-debug] PUT    /api/todos/:id            --> (3 handlers)
11[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
12Please check 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 | | 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.

2  "name": "todos",
3  "version": "0.1.0",
4  "private": true,
5  "proxy": "http://localhost:4000"
6  //...

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              │   └──
14              └── js
15                  ├── main.xxxxxxxx.js
16                  ├── main.xxxxxxxx.js.LICENSE.txt
17                  └──

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
3import (
4  "embed"
5  "fmt"
6  "io/fs"
7  "net/http"
8  "strings"
10  ""
11  ""
14//go:embed build
15var staticFS embed.FS
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))
23// ----------------------------------------------------------------------
24// staticFileSystem serves files out of the embedded build folder
26type staticFileSystem struct {
27  http.FileSystem
30var _ static.ServeFileSystem = (*staticFileSystem)(nil)
32func newStaticFileSystem() *staticFileSystem {
33  sub, err := fs.Sub(staticFS, "build")
35  if err != nil {
36    panic(err)
37  }
39  return &staticFileSystem{
40    FileSystem: http.FS(sub),
41  }
44func (s *staticFileSystem) Exists(prefix string, path string) bool {
45  buildpath := fmt.Sprintf("build%s", path)
47  // support for folders
48  if strings.HasSuffix(path, "/") {
49    _, err := staticFS.ReadDir(strings.TrimSuffix(buildpath, "/"))
50    return err == nil
51  }
53  // support for files
54  f, err := staticFS.Open(buildpath)
55  if f != nil {
56    _ = f.Close()
57  }
58  return err == nil

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)
4  // support for folders
5  if strings.HasSuffix(path, "/") {
6    _, err := staticFS.ReadDir(strings.TrimSuffix(buildpath, "/"))
7    return err == nil
8  }
10  // support for files
11  f, err := staticFS.Open(buildpath)
12  if f != nil {
13    _ = f.Close()
14  }
15  return err == nil

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
3import (
4  ""
6  ""
9func Start() {
10  store := newStore()
11  router := gin.Default()
13  addRoutes(router, store)
14  ui.AddRoutes(router)
16  router.Run(":4000")

Lets build the binary and see it in action. In the project root directory:

1go build

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";
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>
14      <Link className="nav-link" to={"/"}>
15        Return
16      </Link>
17    </>
18  );

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";
8export const Todos = () => {
9  const [todos, setTodos] = useState([]);
11  const fetchTodos = useCallback(async () => {
12    const resp = await fetch("/api/todos");
13    const body = await resp.json();
14    const { todos } = body;
16    setTodos(todos);
17  }, [setTodos]);
19  useEffect(() => {
20    fetchTodos();
21  }, [fetchTodos]);
23  function onDeleteSuccess() {
24    fetchTodos();
25  }
27  function onCreateSuccess(newTodo) {
28    setTodos([...todos, newTodo]);
29  }
31  return (
32    <>
33      <h3>To Do:</h3>
34      <div className="todos">
35        { => (
36          <Todo key={} 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  );

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";
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  );
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
3import (
4  "embed"
5  "fmt"
6  "io/fs"
7  "net/http"
8  "strings"
10  ""
11  ""
14//go:embed build
15var staticFS embed.FS
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))
25// ----------------------------------------------------------------------
26// staticFileSystem serves files out of the embedded build folder
28type staticFileSystem struct {
29  http.FileSystem
32var _ static.ServeFileSystem = (*staticFileSystem)(nil)
34func newStaticFileSystem() *staticFileSystem {
35  sub, err := fs.Sub(staticFS, "build")
37  if err != nil {
38    panic(err)
39  }
41  return &staticFileSystem{
42    FileSystem: http.FS(sub),
43  }
46func (s *staticFileSystem) Exists(prefix string, path string) bool {
47  buildpath := fmt.Sprintf("build%s", path)
49  // support for folders
50  if strings.HasSuffix(path, "/") {
51    _, err := staticFS.ReadDir(strings.TrimSuffix(buildpath, "/"))
52    return err == nil
53  }
55  // support for files
56  f, err := staticFS.Open(buildpath)
57  if f != nil {
58    _ = f.Close()
59  }
60  return err == nil
63// ----------------------------------------------------------------------
64// fallbackFileSystem wraps a staticFileSystem and always serves /index.html
65type fallbackFileSystem struct {
66  staticFileSystem *staticFileSystem
69var _ static.ServeFileSystem = (*fallbackFileSystem)(nil)
70var _ http.FileSystem = (*fallbackFileSystem)(nil)
72func newFallbackFileSystem(staticFileSystem *staticFileSystem) *fallbackFileSystem {
73  return &fallbackFileSystem{
74    staticFileSystem: staticFileSystem,
75  }
78func (f *fallbackFileSystem) Open(path string) (http.File, error) {
79  return f.staticFileSystem.Open("/index.html")
82func (f *fallbackFileSystem) Exists(prefix string, path string) bool {
83  return true

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))

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.


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.


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.


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.

Dave Vanlaningham
Dave Vanlaningham

Related posts

All posts

Get our latest content
in your inbox every week

By subscribing to our Newsletter, you agreed to our Privacy Notice

Community Engagement

Join the Community

Become a part of our thriving community, where you can connect with like-minded individuals, collaborate on projects, and grow together.

Ready to Get Started

Deploy in under 20 minutes with our one line installation script and start configuring your pipelines.

Try it now