Skip to content

Blog

Why Go — not Node

Every SaaS starter has to pick a backend language. Most pick Node or Bun because that’s where the tutorials are. Laju Go picks Go, and the reason isn’t vibes — it’s a set of engineering tradeoffs that compound over the life of a product.

go build produces a single static binary. You ship it to a $6 VPS, ./laju-go, and you’re done. There is no node_modules/ to sync, no runtime to install, no version manager to fight. The Dockerfile is a multi-stage build that ends with COPY /laju-go /laju-go on a FROM scratch-style base. The deploy story is a 12 MB artifact and a systemd unit.

Node ships a runtime. Go ships a binary. For a SaaS that needs to run for years, that difference shows up every time you provision a server, every time you debug a production issue at 2am, and every time you onboard a contributor who just wants to run the thing.

Node’s concurrency model is a single-threaded event loop with async callbacks. It works, but it forces every I/O operation through a callback chain and punishes you with callback hell or async/await coloring if you forget. Go’s model is goroutines — lightweight, multiplexed onto OS threads by the runtime, with no function-coloring.

Laju Go uses this directly. The main process starts a background cleanup goroutine that prunes expired sessions and password-reset tokens every few minutes, while the HTTP server handles requests on the main goroutine:

// Start background cleanup for expired sessions and password reset tokens
startBackgroundCleanup(querier)
// ...later, the server runs in its own goroutine
go func() {
slog.Info("server listening", "port", cfg.AppPort)
if err := app.Listen(":" + cfg.AppPort); err != nil {
slog.Error("server failed", "error", err)
os.Exit(1)
}
}()

No setInterval, no Promise.all, no thinking about whether something blocks the loop. You write sequential code and the runtime handles the scheduling.

Laju Go runs on Fiber, which is built on fasthttp — not net/http. fasthttp is purpose-built for low allocation and high throughput. The Fiber benchmark sits around 300,000 requests per second on commodity hardware, an order of magnitude above Express and well above what Bun’s HTTP layer sustains under real workloads.

The app is configured for it explicitly:

app := fiber.New(fiber.Config{
AppName: "Laju",
ErrorHandler: customErrorHandler,
StreamRequestBody: true, // stream uploads, don't buffer
ReadBufferSize: 64 * 1024, // tuned for large uploads
})

StreamRequestBody: true is the difference between buffering a 2 GB TUS upload in RAM and streaming it to disk. On Node, you’d be configuring streams manually and hoping the framework respects them.

Go’s GC is concurrent and sub-millisecond for small heaps. Laju Go’s heap is small — SQLite is the store, sessions are cached in a sync.RWMutex map, and the query layer is generated code with no reflection. There is no V8-style pause-the-world stop on request paths. The p99 latency is the database, not the runtime.

Go is not the right answer if your team only knows TypeScript, or if you need a library that only exists on npm. It is the right answer when you want a backend that compiles in 2 seconds, deploys as one file, handles hundreds of thousands of requests per second per core, and has a concurrency model that doesn’t require a mental model of the event loop to reason about.

Laju Go is a SaaS starter. The backend is the part that runs forever and costs you money per request. That’s the part worth optimizing — and Go is the optimization.

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 sqlc — not an ORM

The Go ecosystem has ORMs — GORM, ent, Pop. Laju Go uses none of them. It uses sqlc, a compiler that reads SQL and generates type-safe Go. The reasoning is simple: SQL is the right language for database work, and Go is the right language for application logic. An ORM forces you to write SQL in a worse language and then translates it back to SQL at runtime.

The input is a .sql file with named queries. Here’s the entire password-reset query set in Laju Go:

-- queries/password_reset.sql
-- name: CreatePasswordReset :exec
INSERT INTO password_resets (token, user_id, email, expires_at, created_at)
VALUES (?, ?, ?, ?, ?);
-- name: GetPasswordReset :one
SELECT * FROM password_resets WHERE token = ? AND used = 0 AND expires_at > ?;
-- name: MarkPasswordResetUsed :exec
UPDATE password_resets SET used = 1 WHERE token = ?;
-- name: DeleteExpiredPasswordResets :exec
DELETE FROM password_resets WHERE expires_at < CURRENT_TIMESTAMP;

npm run db:generate runs sqlc, which produces Go code in app/queries/ — typed query constants, parameter structs, and methods on a Queries struct. The generated code for CreateUser looks like this:

user.sql
// Code generated by sqlc. DO NOT EDIT.
const createUser = `-- name: CreateUser :one
INSERT INTO users (email, name, password, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING id
`
type CreateUserParams struct {
Email string
Name string
Password sql.NullString
Role string
CreatedAt time.Time
UpdatedAt time.Time
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, createUser,
arg.Email, arg.Name, arg.Password, arg.Role, arg.CreatedAt, arg.UpdatedAt,
)
var id int64
err := row.Scan(&id)
return id, err
}

The service layer calls it like a normal function:

id, err := s.querier.CreateUser(ctx, queries.CreateUserParams{
Email: email,
Name: name,
Password: sql.NullString{String: hash, Valid: true},
Role: "user",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})

No string building. No .Where("email = ?", email).First(&user). No struct tags that secretly control SQL generation. The SQL is the source of truth and the Go is derived from it.

If you rename a column in the schema and forget to update a query, sqlc fails at generation time — before the app compiles. If you pass the wrong type to a parameter, the Go compiler catches it. There is no interface{} reflection at runtime, no “query returned 3 columns but struct has 4” panic in production.

The app/queries/ directory is generated and committed. The rule in AGENTS.md is explicit: never edit app/queries/ manually. You edit the .sql file, run db:generate, and the Go updates. This keeps the SQL as the single source of truth and prevents the generated code from drifting.

GORM and ent use reflection and struct tags to build queries at runtime. That means allocations, interface boxing, and a non-trivial CPU cost per query. sqlc generates plain QueryRowContext / QueryContext calls with the SQL as a string constant. The runtime cost is the database driver and nothing else. For a high-throughput SaaS, that’s measurable.

This is the part that matters in 2026. LLMs are excellent at SQL and mediocre at ORM DSLs. When the data layer is plain SQL, an AI agent can write a new query — -- name: GetUserByEmail :one\nSELECT * FROM users WHERE email = ? — and sqlc generates the Go. The agent doesn’t need to know GORM’s chainable API, ent’s schema definition syntax, or which struct tag means “primary key.” It writes SQL, which is the thing it’s best at, and the compiler does the rest.

The three-tier rule in Laju Go enforces this: the Query layer is the only layer that executes SQL. Services call s.querier.*. Handlers never touch the database. This means an AI agent adding a feature writes SQL + a service method + a handler, and each layer has a clear contract. The ORM version of this task requires the agent to understand the ORM’s mental model, its migration system, and its query builder — all of which are worse than SQL for the database part.

sqlc doesn’t do migrations (Laju Go uses Goose for that). It doesn’t do relationships — there’s no .Preload("orders"). If you want eager loading, you write a JOIN in SQL, which is what you should do anyway. And it’s SQL-first, so if you don’t want to write SQL, it’s not for you. But if you’re building a SaaS and you’re willing to write SQL, sqlc gives you type safety, compile-time checking, zero runtime overhead, and an AI-friendly data layer — all at once.

Why SQLite — not Postgres

Postgres is the default database for a SaaS starter. It’s also the default reason a starter is hard to deploy: you need a server, a port, credentials, a connection pooler, and a backup strategy that isn’t pg_dump on a cron. Laju Go uses SQLite, and for a starter that targets a single-instance VPS, the tradeoff is almost always in SQLite’s favor.

SQLite is a library, not a server process. The database is a single file on disk. There is no postgres daemon to install, no port to open, no DATABASE_URL to configure, no connection pool to tune against a remote host. Laju Go opens it with one line:

db, err := sql.Open("sqlite3", cfg.DBPath)

The DSN is file:data.db?_foreign_keys=on — a path, not a network address. This means the database lives and dies with the app binary. Deploy is scp laju-go data.db server:. Backup is cp data.db data.db.bak. There is no separate failure mode for “the database is up but the app can’t reach it.”

The old knock on SQLite was write concurrency — a single writer lock. WAL (Write-Ahead Logging) mode fixes this. Readers and writers don’t block each other; writes append to a WAL file and checkpoint asynchronously. Laju Go enables it explicitly, along with a set of PRAGMAs tuned for a single-instance NVMe VPS:

func initDatabase(dbPath string) (*sql.DB, error) {
db, err := sql.Open("sqlite3", dbPath)
// ...
db.SetMaxOpenConns(15)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(30 * time.Second)
db.Exec("PRAGMA foreign_keys = ON")
db.Exec("PRAGMA journal_mode = WAL")
db.Exec("PRAGMA synchronous = NORMAL")
db.Exec("PRAGMA cache_size = -16000") // 16 MB cache
db.Exec("PRAGMA mmap_size = 268435456") // 256 MB memory-mapped I/O
db.Exec("PRAGMA temp_store = MEMORY")
db.Exec("PRAGMA busy_timeout = 5000") // wait 5s for locks
db.Exec("PRAGMA wal_autocheckpoint = 1000")
return db, nil
}

synchronous = NORMAL trades strict durability (FULL) for a ~2x write speedup while remaining safe against power loss in WAL mode. mmap_size = 256MB lets SQLite memory-map the file so reads bypass the page cache syscall path. busy_timeout = 5000 means writers wait for a lock instead of failing immediately.

On a modern NVMe drive with WAL mode, SQLite sustains tens of thousands of writes per second and far more reads — more than enough for a SaaS starter’s first 10,000 users. The bottleneck for a typical SaaS is not the database’s raw throughput; it’s the application’s query patterns and indexing. SQLite with the right PRAGMAs handles the load that a single-instance app can generate.

Backing up Postgres is a production concern: pg_dump, WAL archiving, PITR, a replica. Backing up SQLite is:

Terminal window
sqlite3 data.db ".backup data.db.bak"
# or, if the app is stopped:
cp data.db data.db.bak

The .backup command uses the online backup API — it works while the app is running and produces a consistent snapshot. For a starter, this is a one-line cron job. There is no replication slot to fill up, no WAL archive to fill the disk.

SQLite is single-instance. The moment you need horizontal scale — two app servers hitting the same database — you need Postgres. Laju Go’s architecture is built for that day: the query layer is generated by sqlc from plain SQL, so swapping SQLite for Postgres is a matter of changing the driver, the schema types, and regenerating. The three-tier rule (Handler → Service → Query) means no business logic touches the database driver directly.

But most SaaS starters never reach that day. Most never reach the point where a single VPS can’t handle the load. SQLite gets you to revenue without a database server, and the migration path is open when you need it.

SQLite is the wrong choice if you need multi-writer concurrency across nodes, if you need Postgres-specific features (JSONB operators, full-text search via tsvector, logical replication), or if you’re deploying on a network filesystem where file locks are unreliable. It is the right choice for a starter that wants to ship on one box, back up with cp, and not run a database server until the business pays for one.

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.