Embedding a Remix SPA in a Go Backend: A true Full-Stack Solution
I needed to create an internal web app to be deployed on an internal server. I decided it should ideally be a single Docker image to better isolate it from other services hosted on the same server. This approach also makes it easy to deploy and maintain. I will discuss the deployment part in a later article.
Single binary deployment - No need for separate frontend hosting
Improved performance - Direct serving of static assets
Simplified architecture - Reduced infrastructure complexity
Full-stack Go - Leverage Go’s excellent HTTP capabilities
Here’s the key code that makes it all work:
import (
"embed"
"io/fs"
)
//go:embed all:build/client
var distDir embed.FS
var dirPath = "build/client" // path of the front-end build
// DistDirFS contains the embedded dist directory files (without the "dist" prefix)
var DistDirFS, _ = fs.Sub(distDir, dirPath)
In this tutorial, we will focus on embedding a Remix Single Page Application (SPA) into a Go backend server binary.
Let’s start a demo project for this tutorial.
Let’s start by setting up our project structure:
my-app/
├── ui/
│ ├── app/
│ ├── embed.go
│ └── build/
│ └── client/
├── main.go
└── go.mod
First, create your project directory:
mkdir myapp
cd myapp
# or use this helpful shortcut
take myapp # see tip below
💡 Tip: Add this useful function to your
.zshrc
or.bashrc
:# function that create a directory and cd to it take() { mkdir $1 && cd $1 }
Initialize your Go module:
go mod init myapp
Set up the Remix SPA using the official template:
npx create-remix@latest --install --no-git-init --template remix-run/remix/templates/spa ui -y
Create ui/embed.go
to handle the frontend embedding:
// Package ui handles the frontend embedding.
package ui
import (
"embed"
"io/fs"
)
//go:embed all:build/client
var distDir embed.FS
// DistDirFS contains the embedded dist directory files (without the "dist" prefix)
var DistDirFS, _ = fs.Sub(distDir, "build/client")
Implement your main server in main.go
:
package main
import (
"log"
"myapp/ui"
"net/http"
)
func main() {
// API endpoint
http.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message": "Hello from Go!"}`))
})
// Serve static files
fs := http.FileServer(http.FS(ui.DistDirFS))
// Catch-all route for client-side routing
http.Handle("/", http.StripPrefix("/", fs))
log.Println("Server running on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Create a simple frontend page in ui/app/routes/_index.tsx
:
export default function Index() {
const [data, setData] = useState("");
useEffect(() => {
fetch("/api/hello") // api in same origin
.then((res) => res.json())
.then((data) => setData(data.message));
}, []);
return (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center gap-16">
<header className="flex flex-col items-center gap-9">
<h1>Welcome to Remix (SPA) + Go!</h1>
<p>Message from Go backend: {data}</p>
</header>
</div>
</div>
);
}
Build the frontend:
cd ui
npm install
npm run build
cd ..
Start the server:
go run main.go
Visit http://localhost:8080/
to see your application in action!
Congrats, now your Go backend server is serving an embedded React SPA, from a single, deployable binary that serves both your frontend and backend.