Complimentary Gartner® Report! 'A CTO's Guide to Open-Source Software: Answering the Top 10 FAQs.'Read more
Technical “How-To’s”

How to Embed React in Golang

Dave Vanlaningham
Dave Vanlaningham
Share:

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:

Getting started

You can go ahead and clone the starting point for our app.

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

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

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

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

text
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

text
1npm run build

We now have a build folder with some files in it:

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

text
1touch ui/ui.go

Copy and paste this code:

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

text
1go mod tidy

Let's break this down a bit. Note lines 14 and 15.

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

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

text
1router.Use(static.Serve("/", embeddedBuildFolder))

Let's add this route in our API/start.go file, which now looks like this:

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

text
1go build
text
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/

text
1npm install react-router-dom

Let's add an About page. From the project directory:

text
1touch ui/src/components/About.jsx

Copy this into it.

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

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

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

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

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

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

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

Dave Vanlaningham
Dave Vanlaningham
Share:

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