- Add internal/spa package for embedding React build - Wire SPA handler in main.go for non-API routes - Add gitignore entry for internal/spa/dist - Add implementation plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8.8 KiB
Frontend Replacement Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the Go template-based frontend with the search-zen-50 React SPA, embedded in the Go binary as a single deployment.
Architecture: Build React app → embed in Go binary via //go:embed → serve via Go HTTP server with SPA fallback routing. React calls /search?format=json and /autocompleter?q= APIs.
Tech Stack: Go (embed), React 18, Vite, TailwindCSS, React Router, @tanstack/react-query
File Map
| File | Action |
|---|---|
cmd/kafka/main.go |
Modify - replace template handlers with SPA handler |
internal/spa/spa.go |
Create - embed React build, serve static files, SPA fallback |
internal/spa/dist/ |
Build output - React build artifacts (gitignored) |
src/hooks/use-search.ts |
Modify - replace mock with real API calls |
src/lib/mock-data.ts |
Keep types, remove MOCK_RESPONSE usage |
Task 1: Build React App
Files:
-
Build:
/tmp/search-zen-50/dist/(output directory) -
Step 1: Install dependencies and build
cd /tmp/search-zen-50 && bun install && bun run build
Expected: dist/ directory created with index.html, assets/ folder containing JS/CSS bundles
- Step 2: Verify dist contents
ls /tmp/search-zen-50/dist/ && ls /tmp/search-zen-50/dist/assets/ | head -10
Expected: index.html exists, assets/ contains .js and .css files
Task 2: Create SPA Go Package
Files:
- Create:
internal/spa/spa.go
package spa
import (
"embed"
"io/fs"
"net/http"
"path"
)
//go:embed all:dist
var distFS embed.FS
// DistFS returns the embedded dist directory as an fs.FS.
func DistFS() (fs.FS, error) {
return fs.Sub(distFS, "dist")
}
// NewHandler returns an HTTP handler that:
// - Serves static files from the embedded dist/ directory
// - Falls back to index.html for SPA routing (any non-API path)
func NewHandler() http.Handler {
dist, err := DistFS()
if err != nil {
panic("spa: embedded dist not found: " + err.Error())
}
return &spaHandler{dist: dist}
}
type spaHandler struct {
dist fs.FS
}
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// API paths are handled by Go API handlers - this should never be reached
// since Go mux dispatches to specific handlers first. But if reached,
// pass through to FileServer which will return 404 for unknown paths.
// Try to serve the requested file first
filePath := path.Clean(r.URL.Path)
f, err := h.dist.Open(filePath)
if err == nil {
f.Close()
// File exists - serve it via FileServer
http.FileServer(http.FS(h.dist)).ServeHTTP(w, r)
return
}
// Fallback to index.html for SPA routing
indexFile, err := h.dist.Open("index.html")
if err != nil {
http.Error(w, "index.html not found in embedded files", http.StatusInternalServerError)
return
}
indexFile.Close()
http.FileServer(http.FS(h.dist)).ServeHTTP(w, r)
}
Task 3: Wire SPA Handler in main.go
Files:
-
Modify:
cmd/kafka/main.go -
Step 1: Replace handlers with SPA
In main.go, find and replace the mux.HandleFunc section (lines 82-88) and the static file serving section (lines 90-96).
Old code (lines 82-96):
mux := http.NewServeMux()
mux.HandleFunc("/", h.Index)
mux.HandleFunc("/healthz", h.Healthz)
mux.HandleFunc("/search", h.Search)
mux.HandleFunc("/autocompleter", h.Autocompleter)
mux.HandleFunc("/preferences", h.Preferences)
mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL))
// Serve embedded static files (CSS, JS, images).
staticFS, err := views.StaticFS()
if err != nil {
log.Fatalf("failed to load static files: %v", err)
}
var subFS fs.FS = staticFS
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subFS))))
New code:
mux := http.NewServeMux()
// API routes - handled by Go
mux.HandleFunc("/healthz", h.Healthz)
mux.HandleFunc("/search", h.Search)
mux.HandleFunc("/autocompleter", h.Autocompleter)
mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL))
// SPA handler - serves React app for all other routes
spaHandler := spa.NewHandler()
mux.Handle("/", spaHandler)
- Step 2: Add spa import
Add to imports (after "github.com/metamorphosis-dev/kafka/internal/search"):
"github.com/metamorphosis-dev/kafka/internal/spa"
- Step 3: Remove unused views import if needed
If views is only used for StaticFS(), remove the import. The template rendering functions (RenderIndex, etc.) won't be needed anymore.
- Step 4: Verify build
cd /home/ashie/git/kafka && go build ./cmd/kafka/
Expected: Builds successfully (may fail on embed if dist not found - continue to next task)
Task 4: Wire React to Real API
Files:
-
Modify:
src/hooks/use-search.tsin/tmp/search-zen-50/ -
Step 1: Replace mock search with real API call
Replace the search function in use-search.ts:
Old code (lines 23-36):
const search = useCallback(async (query: string) => {
if (!query.trim()) return;
setState((prev) => ({ ...prev, query, isLoading: true, error: null, hasSearched: true }));
// Simulate network delay
await new Promise((r) => setTimeout(r, 800));
setState((prev) => ({
...prev,
isLoading: false,
results: { ...MOCK_RESPONSE, query },
}));
}, []);
New code:
const search = useCallback(async (query: string) => {
if (!query.trim()) return;
setState((prev) => ({ ...prev, query, isLoading: true, error: null, hasSearched: true }));
try {
const response = await fetch(`/search?format=json&q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setState((prev) => ({
...prev,
isLoading: false,
results: data,
}));
} catch (err) {
setState((prev) => ({
...prev,
isLoading: false,
error: err instanceof Error ? err.message : "Search failed",
}));
}
}, []);
- Step 2: Remove mock data import
Remove the mock import line (should be near line 2):
import { MOCK_RESPONSE, type SearXNGResponse, type Category } from "@/lib/mock-data";
Replace with:
import type { SearXNGResponse, Category } from "@/lib/mock-data";
- Step 3: Keep the CATEGORIES export
Ensure mock-data.ts still exports CATEGORIES and Category type. The file should look like:
// Keep these exports - used by CategoryTabs and preferences
export const CATEGORIES = ["general", "it", "images", "news"] as const;
export type Category = typeof CATEGORIES[number];
// Keep interfaces
export interface SearchResult {
url: string;
title: string;
content: string;
engine: string;
parsed_url: [string, string, string, string, string];
engines: string[];
positions: number[];
score: number;
category: string;
pretty_url: string;
img_src?: string;
thumbnail?: string;
publishedDate?: string;
}
export interface SearXNGResponse {
query: string;
number_of_results: number;
results: SearchResult[];
answers: string[];
corrections: string[];
infoboxes: any[];
suggestions: string[];
unresponsive_engines: string[];
}
Task 5: Rebuild React and Verify
Files:
-
Build:
/tmp/search-zen-50/dist/ -
Step 1: Rebuild with changes
cd /tmp/search-zen-50 && bun run build
- Step 2: Copy dist to kafka
rm -rf /home/ashie/git/kafka/internal/spa/dist
cp -r /tmp/search-zen-50/dist /home/ashie/git/kafka/internal/spa/dist
- Step 3: Verify Go build
cd /home/ashie/git/kafka && go build ./cmd/kafka/ && echo "Build successful"
Expected: "Build successful"
Task 6: Test the Integration
- Step 1: Start the server
cd /home/ashie/git/kafka && ./kafka -config config.toml &
sleep 2
- Step 2: Test homepage
curl -s http://localhost:8080/ | head -20
Expected: HTML with <div id="root"></div> from React app
- Step 3: Test API
curl -s "http://localhost:8080/search?format=json&q=test" | head -50
Expected: JSON search response
- Step 4: Clean up
pkill -f "./kafka" 2>/dev/null; echo "Done"
Dependencies
- Node.js/Bun for building React app
- Go 1.24+ for embed functionality
- No new Go dependencies
Notes
- The
internal/spa/dist/folder should be gitignored (build artifact) - The
internal/spa/dist/copy is needed for the embed to work at compile time - Preferences page is entirely client-side (localStorage) - no backend needed
- Autocomplete can be added later by modifying
SearchInput.tsxto call/autocompleter