Skip to content

frontend

3 posts with the tag “frontend”

Why Inertia — not SPA + API

The conventional SaaS architecture is two apps: a React/Vue SPA and a JSON API. You build the API, then you build the SPA, then you build the glue — routing, auth state, fetch wrappers, error handling, loading spinners, optimistic updates. Inertia.js deletes the middle layer. Laju Go is built on it, and the result is a server-driven SPA with no separate API.

With Inertia, a GET request returns an HTML shell on the first load and a JSON page payload on subsequent XHRs. The Go handler decides what component renders and what props it gets — exactly like a server-rendered app, except the client swaps the component without a full reload.

Here’s a Laju Go handler rendering a page:

func (h *AppHandler) Dashboard(c *fiber.Ctx) error {
user, err := h.store.Get(c)
if err != nil {
return h.inertiaService.Redirect(c, "/login")
}
return h.inertiaService.Render(c, "app/Dashboard", fiber.Map{
"user": user,
})
}

Render(c, "app/Dashboard", props) tells Inertia: render the Svelte component at app/Dashboard with these props. On a normal browser hit, it returns the HTML shell with the props serialized into a JSON <div data-page>. On an Inertia XHR (X-Inertia: true), it returns just the JSON. The Svelte side mounts the component. No API endpoint, no client-side router, no data-fetching hook.

The other half is form submission. Inertia’s useForm handles POST/PUT/PATCH with CSRF, loading state, error bag, and redirect-following built in:

<script lang="ts">
import { useForm } from "@inertiajs/svelte";
const form = useForm({ email: "", password: "" });
function submitForm(e: Event) {
e.preventDefault();
form.post("/login");
}
</script>

form.post("/login") POSTs the form fields as form-encoded data with the CSRF token, follows the response, and updates form.errors and form.processing automatically. The Go handler doesn’t know it’s talking to Inertia — it just parses the body, calls the service, and redirects.

After a successful POST, the handler returns a 303 See Other. Inertia intercepts it and does a partial reload to the new page. This is the same control flow as a traditional server-rendered app — POST, redirect, GET — except the client doesn’t blink:

func (h *AuthHandler) Login(c *fiber.Ctx) error {
var req models.LoginRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
user, err := h.authService.Login(req.Email, req.Password)
if err != nil {
if errors.Is(err, services.ErrInvalidCredentials) {
h.store.Flash(c, "error", "Invalid email or password")
return h.inertiaService.Redirect(c, "/login")
}
h.store.Flash(c, "error", "Failed to login. Please try again.")
return h.inertiaService.Redirect(c, "/login")
}
h.store.CreateAuthenticatedSession(c, user.ID, user.Name, ...)
return h.inertiaService.Redirect(c, "/app")
}

No API response shape to design. No status: "success" field. No client-side code that maps API errors to form fields. The flash message is set server-side, consumed on the next render, and gone.

The initial HTML shell — the <head>, the asset tags, the root <div data-page> — is rendered by a templ component, not by a JS bundle. That means the first byte of HTML is real, crawlable, and fast. The Svelte app hydrates into it. You get SPA navigation after the first load and a server-rendered shell on the first request, without running a Node SSR server.

For OAuth flows that leave the app, Inertia has a separate mechanism — Location returns a 409 with X-Inertia-Location, which the client turns into a window.location assignment:

func (h *AuthHandler) GoogleLogin(c *fiber.Ctx) error {
state := generateState()
url := h.authService.GoogleAuthURL(state)
return h.inertiaService.Location(c, url)
}

Inertia is not for apps that need a public JSON API consumed by mobile clients or third parties. If your product is “an API that happens to have a web UI,” you need the API. Laju Go’s assumption is the opposite: the web UI is the product, and the API is an implementation detail of the server. For that shape of app, Inertia removes an entire layer of code — the client-side data layer — and lets the Go handlers be the single source of truth for routing, auth, and data.

Why Svelte 5 — not React or Vue

The frontend choice for Laju Go is Svelte 5. Not React, not Vue. This isn’t a fashion call — it’s a decision driven by three things: the reactivity model, the compilation step, and how cleanly it integrates with a server-driven architecture.

Svelte 5 introduced runes — $state, $derived, $props, $effect — as the reactivity primitives. They look like function calls but are compiler directives. The difference from React hooks is structural: runes have no rules-of-hooks constraints, no dependency arrays, no stale-closure traps.

Here’s the actual profile page in Laju Go:

<script lang="ts">
interface Props {
user?: User;
success?: string;
error?: string;
}
let { user, success, error }: Props = $props();
const profileForm = useForm("EditProfile", {
name: user?.name ?? "",
email: user?.email ?? "",
avatar: user?.avatar ?? "",
});
let showPassword = $state(false);
let previewUrl = $derived(user?.avatar ?? null);
</script>

$props() receives server data. $state(false) is local reactive state. $derived(...) is a computed value that updates when its dependencies change — no useMemo, no dependency array to get wrong. You declare what the value is, not how to recompute it on render.

The rules are short and load-bearing: use $derived() for derived state, $state(value ?? default) to init from props, and reserve $effect for actual side effects (document.title, localStorage). There is no useEffect-as-derived-state anti-pattern because the primitive for that exists.

Svelte is a compiler. At build time it turns .svelte files into vanilla JavaScript that directly manipulates the DOM. There is no virtual DOM, no diffing, no reconciliation pass at runtime. React reconciles every render; Svelte emits code that touches only the nodes that changed.

The practical consequence is bundle size and runtime cost. A Svelte 5 component ships as a few hundred bytes of JS that wires up event listeners and updates bindings. The same component in React ships the component plus the React runtime plus the JSX transform, and pays a reconciliation tax on every state change. For a SaaS dashboard with 30 reactive widgets, that’s the difference between a 40 KB JS bundle and a 180 KB one.

Laju Go uses Inertia.js to drive the SPA from the server. Svelte’s Inertia adapter gives you useForm and use:inertia — the two primitives that replace an entire API layer:

<script lang="ts">
import { inertia, useForm } from "@inertiajs/svelte";
const form = useForm({ email: "", password: "" });
function submitForm(e: Event) {
e.preventDefault();
form.post("/login");
}
</script>
<form onsubmit={submitForm}>...</form>

form.post("/login") submits to the Go handler, which validates, creates a session, and returns a 303 redirect. No fetch, no JSON.stringify, no client-side state machine for loading/error/success. The server is the source of truth and Inertia is the transport.

React is the default, but the default is expensive: a larger runtime, a mental model built around renders and effects, and an ecosystem that assumes you’re building a client-side app with its own routing and data layer. Laju Go’s architecture is server-driven — the Go handlers own routing, auth, and data. React’s client-side machinery is overhead you pay for and don’t use.

Vue 3’s composition API is good and Vue is also compiled. But Svelte 5’s runes are a cleaner reactivity story than Vue’s ref/reactive/computed split, and Svelte’s output is smaller. For a starter that prioritizes a small, fast, server-driven frontend, Svelte 5 is the tighter fit.

Svelte 5 is newer than React and has a smaller ecosystem. If you need a niche component that only exists as an npm package for React, you’ll write it yourself. For a SaaS boilerplate where the frontend is forms, tables, and dashboards driven by a Go backend, that’s a trade worth making. You get a smaller bundle, a simpler reactivity model, and a compiler that catches mistakes before they reach the browser.

Why templ — not HTML templates

Go’s standard html/template package is fine for a blog. For a SaaS landing page with SVG logos, conditional classes, component composition, and a design system, it becomes a liability: string interpolation, runtime errors, no type checking on the data you pass in. Laju Go uses templ for its server-rendered HTML — the landing page, the Inertia shell — and the difference is structural.

templ is a typed template language that compiles to Go. You write .templ files; templ generate produces *_templ.go files that render to an io.Writer. The parameters are typed Go function arguments, not interface{} passed through a map.

Here’s the signature of Laju Go’s landing page:

templates/index.templ
templ LandingPage(title string, isDev bool, viteURL string, mainCSS string) {
{{
productLinks := FooterLinks{
{FLabel: "Features", FURL: "#features"},
{FLabel: "How it works", FURL: "#how-it-works"},
}
}}
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8"/>
<title>{ title } - Laju Go</title>
// ...
</head>
<body>
// ...
</body>
</html>
}

title is a string. isDev is a bool. If you pass the wrong type, the Go compiler tells you at build time. There is no template.Execute(w, data) that panics at runtime because a field was missing or the wrong type.

templ generate runs before go build. If a template references an undefined variable, has a syntax error, or calls a component with the wrong arguments, generation fails. The error is at build time, not at the first request that hits the route. This is the same guarantee sqlc gives you for SQL — the bad code never reaches production.

The generated file is index_templ.go, and the rule in AGENTS.md is explicit: edit only .templ files, never *_templ.go. The generated file is overwritten on every templ generate. This keeps the source of truth in the .templ file and prevents hand-edits from being silently destroyed.

html/template escapes by default, but you’re still building strings. templ renders directly to a Buffer / Writer with proper context-aware escaping built into the compiler. There is no fmt.Sprintf("<div class='%s'>", class) — that’s a string, not HTML, and it’s how XSS happens. In templ:

<div class={ "px-6 py-3 rounded-lg bg-brand-400 " + extraClass }>
{ user.Name }
</div>

The { } expressions are type-checked and escaped by context. { user.Name } in an HTML text node is HTML-escaped. The same expression in an attribute context is attribute-escaped. You don’t think about it, and you can’t get it wrong.

The landing page has inline SVG logos, gradient definitions, and path data. In html/template, inline SVG is a wall of raw strings or template.HTML escapes that bypass safety. In templ, SVG is just markup — you write it directly, the compiler parses it, and it renders as-is:

<svg width="28" height="28" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="logoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#22d3ee"/>
<stop offset="100%" stop-color="#a855f7"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#logoGrad)"/>
<path d="M19 7L10 17h5l-1 8 9-10h-5l1-8Z" fill="white"/>
</svg>

Components are Go functions. @FeatureCardLarge("auth", "Authentication", "...", "brand") is a call to another templ component, type-checked at compile time. You compose pages from components the same way you compose Svelte components — except the output is server-rendered HTML with zero client-side JS.

templ renders two things: the public landing page (standalone HTML, no Inertia) and the Inertia root shell (the <head>, asset tags, and <div data-page> that Inertia hydrates into). The Svelte app handles everything inside the authenticated app. This split is deliberate — the marketing page should be fast, crawlable HTML with no JS bundle, and templ makes that type-safe. The app UI is interactive and uses Svelte. Each tool does what it’s best at.

templ requires a build step (templ generate) and a CGO-free Go toolchain. The syntax is not Go — it’s templ’s own DSL — so there’s a small learning curve, and editor support is good but not universal. If you’re rendering a single simple page, html/template is less machinery. For a SaaS with a real landing page, a design system, and SVG assets, templ’s compile-time safety and component model are worth the build step. You get type-checked HTML, no string interpolation, and errors at build time instead of in production.