Free Report! Gartner® Hype Cycle™ for Monitoring and Observability.Read more
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, 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 – maybe 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 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.

This may be what you want. You should 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.


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

Here, we have a To-Do application. Unimaginative, yet still a cornerstone of web development tutorials.

Without getting 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.

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

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

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

Let's 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 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, very cool; we 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";
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  );

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, 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
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 some things here, Including our newest struct, fallbackFileSystem. We’ve implemented our own Exists and Open methods, ensuring that it always returns 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 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.


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 for compiling 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.


It's 2022, and when we saw the chance to eliminate a microservice, we took it. 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 occasionally serve up some JavaScript and CSS files. 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.


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