gary.info

here be dragons

SvelteKit SPA with a Go API Backend

svelte-spa-go.md

Building a SvelteKit SPA with a Go API Backend: A Comprehensive Brainstorm

Combining a SvelteKit Single-Page Application (SPA) with a Go backend offers a powerful, performant, and cost-effective stack. This approach gives you the rich, modern developer experience of SvelteKit for the frontend, while leveraging Go's speed, simplicity, and concurrency for the backend API. Here's a detailed breakdown of how to approach building such an application, drawing on community discussions and best practices.

Core Architecture: Decoupled Frontend and Backend

The fundamental concept is to create two distinct applications:

  • SvelteKit Frontend (SPA): A client-side rendered application that handles all the UI and user interactions. It will be built into a set of static HTML, CSS, and JavaScript files.
  • Go Backend (API): A server-side application that exposes a REST or GraphQL API for the SvelteKit frontend to consume. It will also be responsible for serving the static files of the SvelteKit application in a production environment.
  • This decoupled architecture provides flexibility in development, deployment, and scaling of both the frontend and backend independently.


    Part 1: Setting up the SvelteKit Frontend as an SPA

    SvelteKit is a full-stack framework by default, but it can be configured to output a client-side rendered SPA. This is achieved by using the adapter-static.

    1. Initialize a new SvelteKit project:

    npm create svelte@latest my-svelte-app
    cd my-svelte-app
    npm install

    2. Install adapter-static:

    This adapter will build your SvelteKit app into a collection of static files.

    npm i -D @sveltejs/adapter-static

    3. Configure svelte.config.js for SPA mode:

    Modify your svelte.config.js to use the static adapter and specify a fallback page. The fallback page is crucial for an SPA as it allows the client-side router to handle all routes.

    import adapter from '@sveltejs/adapter-static';
    
    /** @type {import('@sveltejs/kit').Config} */
    const config = {
      kit: {
        adapter: adapter({
          pages: 'build',
          assets: 'build',
          fallback: 'index.html', // or 200.html depending on your hosting provider
          precompress: false,
          strict: true
        })
      }
    };
    
    export default config;

    4. Disable Server-Side Rendering (SSR):

    To ensure your application is a true SPA, disable SSR in your root layout file (src/routes/+layout.js or src/routes/+layout.ts). This tells SvelteKit to only render on the client-side.

    // src/routes/+layout.js
    export const ssr = false;

    With this setup, when you run npm run build, SvelteKit will generate a build directory containing the static assets for your SPA.


    Part 2: Building the Go API Backend

    Your Go backend will have two primary responsibilities: providing the API endpoints and serving the SvelteKit SPA.

    1. Project Structure:

    A common approach is to have a monorepo structure:

    /my-project
      /frontend  // Your SvelteKit app
      /backend   // Your Go app

    2. Creating a simple Go web server:

    You can use the standard library's net/http package or a popular router like gorilla/mux or chi.

    package main
    
    import (
    	"fmt"
    	"log"
    	"net/http"
    )
    
    func main() {
    	// API routes
    	http.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) {
    		fmt.Fprintf(w, "Hello from the Go API!")
    	})
    
    	// Serve the SvelteKit SPA
    	fs := http.FileServer(http.Dir("./frontend/build"))
    	http.Handle("/", fs)
    
    	log.Println("Listening on :8080...")
    	err := http.ListenAndServe(":8080", nil)
    	if err != nil {
    		log.Fatal(err)
    	}
    }

    3. Handling SPA Routing in Go:

    A key challenge with SPAs is that refreshing the page on a route other than the root (e.g., /profile) will result in a 404 error if the server doesn't know how to handle it. The solution is to have your Go server redirect all non-API, non-file requests to the index.html of your SvelteKit app.

    Here's a more robust way to handle this:

    package main
    
    import (
    	"log"
    	"net/http"
    	"os"
    	"path/filepath"
    	"strings"
    )
    
    func main() {
    	// API handler
    	http.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) {
    		w.Write([]byte("Hello from Go!"))
    	})
    
    	// Static file server for the SvelteKit app
    	staticDir := "./frontend/build"
    	fileServer := http.FileServer(http.Dir(staticDir))
    
    	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    		// If the request is for an API endpoint, let the API handler take over
    		if strings.HasPrefix(r.URL.Path, "/api") {
    			http.NotFound(w, r) // Or handle with your API router
    			return
    		}
    
    		// Check if the requested file exists in the static directory
    		_, err := os.Stat(filepath.Join(staticDir, r.URL.Path))
    		if os.IsNotExist(err) {
    			// If the file doesn't exist, serve the index.html for client-side routing
    			http.ServeFile(w, r, filepath.Join(staticDir, "index.html"))
    			return
    		}
    
    		// Otherwise, serve the static file
    		fileServer.ServeHTTP(w, r)
    	})
    
    
    	log.Println("Starting server on :8080")
    	if err := http.ListenAndServe(":8080", nil); err != nil {
    		log.Fatal(err)
    	}
    }


    Part 3: Connecting SvelteKit to the Go API

    1. Data Fetching in SvelteKit:

    You can fetch data from your Go API within your Svelte components using the browser's fetch API, typically within the onMount lifecycle function or in a load function in a +page.js file.

    <!-- src/routes/+page.svelte -->
    <script>
      import { onMount } from 'svelte';
    
      let message = 'Loading...';
    
      onMount(async () => {
        const response = await fetch('/api/hello');
        message = await response.text();
      });
    </script>
    
    <h1>{message}</h1>

    2. Managing Environment Variables:

    To avoid hardcoding your API URL, use environment variables in SvelteKit.

  • Create a .env file in your SvelteKit project's root:
  • PUBLIC_API_URL=http://localhost:8080

  • Access it in your SvelteKit code using $env/dynamic/public:
  • import { env } from '$env/dynamic/public';
    
        const apiUrl = env.PUBLIC_API_URL;

    3. Handling CORS (Cross-Origin Resource Sharing):

    During development, your SvelteKit dev server (e.g., on port 5173) and your Go backend (e.g., on port 8080) will be on different origins. This will cause browsers to block requests due to CORS policy. To fix this, you need to enable CORS on your Go backend.

    Here's an example using the rs/cors library in Go:

    // In your Go main function
    import (
    	"net/http"
    	"github.com/rs/cors"
    )
    
    func main() {
    	// ... your handlers
    
    	c := cors.New(cors.Options{
    		AllowedOrigins: []string{"http://localhost:5173"},
    		AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
    		AllowedHeaders: []string{"*"},
    	})
    
    	handler := c.Handler(http.DefaultServeMux)
    	log.Fatal(http.ListenAndServe(":8080", handler))
    }


    Part 4: Authentication

    Authentication between a SvelteKit SPA and a Go backend is typically handled using tokens (like JWTs) stored in cookies or local storage.

    A common flow:

  • The user submits login credentials from the SvelteKit app to a /api/login endpoint on the Go server.
  • The Go server validates the credentials and, if successful, generates a JWT.
  • The Go server sends this JWT back to the SvelteKit app, often in an HTTP-only cookie for better security.
  • For subsequent requests to protected API endpoints, the browser automatically includes the cookie with the JWT.
  • The Go backend has middleware that inspects the JWT on incoming requests to protected routes, validates it, and authorizes the request.
There are several tutorials and examples available that demonstrate session-based authentication with Go and SvelteKit.


Part 5: Deployment

A significant advantage of this stack is the ease of deployment. You can compile your Go backend and embed the static SvelteKit frontend files into a single binary.

1. Build the SvelteKit App:

cd frontend
npm run build

2. Embed the Frontend in the Go Binary:

Use Go's embed package to bundle the SvelteKit build output into your Go executable.

package main

import (
	"embed"
	"io/fs"
	"log"
	"net/http"
)

//go:embed all:frontend/build
var embeddedFiles embed.FS

func main() {
	// ... your API handlers

	// Create a sub-filesystem that serves from the 'frontend/build' directory
	// within the embedded files.
	subFS, err := fs.Sub(embeddedFiles, "frontend/build")
	if err != nil {
		log.Fatal(err)
	}

	http.Handle("/", http.FileServer(http.FS(subFS)))

	log.Println("Listening on :8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Now, when you build your Go application (go build), the resulting binary will contain your entire frontend and can be deployed as a single file. This simplifies deployment to services like Fly.io, a VPS, or any platform that can run a Go binary.