Single page applications (SPAs) are generally not the first tool I reach for when building a new website. Unless I’m starved for reactivity, I find managing user credentials, securing my API, and shipping a large bundle to the client to process frankly over-stressful and unnecessary. I’d much rather use a server-side multi-page app (MPA) with HTMX or Alpine.JS to achieve dynamic hydration where it counts without having to worry about exposing logic or secrets to the client.
However, recently I started building a realtime multiplayer board game using websockets. I needed a tech stack that could handle regular state changes from the server while updating a dynamic game board. Out of everything I found, Solid JS seemed to be the simplest, most performant framework I could integrate with Go.
Setting up Vite
You can set up a new Solid project easily by running
bunx degit solidjs/templates/ts my-app
Then move 'package.json', 'tsconfig.json', 'vite.config.ts', 'index.html' and 'postcss.config.js' out of your 'my-app' directory into the root of your Go project. This centers your root as the heart of your project and follows Go best practice. Next, modify the index html so the 'script' tag source is accurate to your 'index.tsx'.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link
rel="shortcut icon"
type="image/ico"
href="/client/assets/favicon.ico"
/>
<title>My App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/my-app/index.tsx" type="module"></script>
</body>
</html>
Finally, update your 'vite.config.ts'’s server.proxy object to support your application’s API route:
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
"/ws": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
Now, you can configure your backend to render your app’s development server and static assets depending on your development or production environment.
Using Go Fiber
DEBUG in this example represents any methodology you might use to determine if you're running your app in production.
if DEBUG {
app.Use(func(c *fiber.Ctx) error {
// Proxy requests to Vite dev server
if c.Path() == "/" || strings.HasPrefix(c.Path(), "/assets/") {
return proxy.Do(c, "http://localhost:5173")
}
return c.Next()
})
app.Get("/*", func(c *fiber.Ctx) error {
// Redirect to Vite dev server
return c.Redirect("http://localhost:5173")
})
} else {
// Serve built assets in production
app.Use("/assets", filesystem.New(filesystem.Config{
Root: http.Dir("./dist/assets"),
}))
app.Get("/*", func(c *fiber.Ctx) error {
return c.SendFile("./dist/index.html")
})
}
Using Gin
if gin.Mode() == gin.DebugMode {
// Serve Vite in development
router.Use(func(c *gin.Context) {
if c.Request.URL.Path == "/" || strings.HasPrefix(c.Request.URL.Path, "/assets/") {
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: "localhost:5173",
})
proxy.ServeHTTP(c.Writer, c.Request)
return
}
c.Next()
})
// Fallback route should be last
router.NoRoute(func(c *gin.Context) {
c.Redirect(http.StatusFound, "http://localhost:5173")
})
} else {
router.Use(static.Serve("/assets", static.LocalFile("./dist/assets", false)))
router.NoRoute(func(c *gin.Context) {
c.File("./dist/index.html")
})
}