Building This Blog: SvelteKit + Rust From Scratch

Every developer eventually builds their own blog. It is a rite of passage — and a procrastination strategy disguised as productivity. This is the story of building mine: a SvelteKit frontend backed by a Rust API, deployed with Docker Compose, and authenticated via GitHub OAuth. The whole thing was built in a single session with Claude as a copilot, and what followed was an education in all the things that look simple on paper but explode in practice.

The Architecture

The high-level design is two services talking over an internal Docker network:

Browser → SvelteKit (SSR, port 3000) → Rust Axum API (port 8080) → SQLite

SvelteKit handles server-side rendering, routing, the public blog pages, and the admin UI with a TipTap WYSIWYG editor. Its +page.server.ts load functions call the Rust API over HTTP, so the browser never talks to the backend directly (except for the OAuth redirect, which caused its own set of problems — more on that later).

Rust/Axum handles the REST API, GitHub OAuth, JWT session management, image uploads, and all database operations. SQLite was chosen over PostgreSQL because this is a single-author personal blog — a single file database that you can back up with cp is the right tool for the job.

The Backend: Rust + Axum + SQLite

Why Axum

Axum is the dominant Rust web framework for new projects. It is built on top of tokio and tower, which means you get the entire Tower middleware ecosystem for free. The API surface is small and the error messages are (by Rust standards) reasonable.

Database Layer

The schema is three tables:

CREATE TABLE IF NOT EXISTS posts (
    id           TEXT PRIMARY KEY,
    title        TEXT NOT NULL,
    slug         TEXT NOT NULL UNIQUE,
    content      TEXT NOT NULL DEFAULT '',
    excerpt      TEXT,
    status       TEXT NOT NULL DEFAULT 'draft',
    published_at TEXT,
    created_at   TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at   TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS tags (
    id   TEXT PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    slug TEXT NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS post_tags (
    post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    tag_id  TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (post_id, tag_id)
);

I used sqlx for async database access. One early lesson: SQLx does not create the database file by default. The initial Docker deployment panicked on startup with SqliteError { code: 14, message: "unable to open database file" }. The fix was switching from .connect(url) to using SqliteConnectOptions with .create_if_missing(true):

let options = SqliteConnectOptions::from_str(database_url)
    .expect("Invalid DATABASE_URL")
    .create_if_missing(true);

let pool = SqlitePoolOptions::new()
    .max_connections(5)
    .connect_with(options)
    .await
    .expect("Failed to create database pool");

Another gotcha: the migration SQL used CREATE INDEX without IF NOT EXISTS. Since migrations run on every startup (simple approach for a personal blog), the second launch crashed because the indexes already existed. Always use IF NOT EXISTS for idempotent migrations.

Authentication

The auth flow uses GitHub OAuth with JWT sessions:

  1. Browser visits /admin, SvelteKit detects no auth cookie, redirects to /api/auth/github
  2. Axum redirects to GitHub's OAuth authorization page
  3. User authorizes, GitHub redirects back with an authorization code
  4. Axum exchanges the code for an access token, fetches the user profile, verifies the GitHub user ID against ALLOWED_GITHUB_ID
  5. Axum creates a JWT and redirects to the SvelteKit callback with the token in the URL
  6. SvelteKit stores the JWT in an httpOnly cookie

The admin middleware is a simple Axum middleware that extracts the Bearer token, verifies the JWT signature, and checks the GitHub user ID:

pub async fn require_auth(request: Request, next: Next) -> Result<Response, StatusCode> {
    let token = request.headers()
        .get("Authorization")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .ok_or(StatusCode::UNAUTHORIZED)?;

    let claims = verify_token(token, &jwt_secret)
        .map_err(|_| StatusCode::UNAUTHORIZED)?;

    if claims.sub != allowed_id {
        return Err(StatusCode::FORBIDDEN);
    }

    Ok(next.run(request).await)
}

The Test Suite

The backend has 22 tests covering all CRUD operations, auth middleware, file upload validation, and tag management. Every test uses an in-memory SQLite database via sqlx::connect("sqlite::memory:") and the axum-test crate for HTTP-level assertions:

#[tokio::test]
async fn test_create_publish_list_get() {
    let server = test_server().await;

    let res = server.post("/api/admin/posts")
        .json(&json!({
            "title": "Hello World",
            "content": "<p>First post</p>",
            "tags": ["rust", "blog"]
        }))
        .await;
    res.assert_status(StatusCode::CREATED);
    let created: PostWithTags = res.json();
    assert_eq!(created.post.status, "draft");

    // Publish and verify it appears in the public list
    server.post(&format!("/api/admin/posts/{}/publish", created.post.id)).await;
    let list: PostList = server.get("/api/posts").await.json();
    assert_eq!(list.posts.len(), 1);
}

The Frontend: SvelteKit 2 + Svelte 5

SSR for SEO

Every public page uses server-side rendering via +page.server.ts load functions. This means the HTML arrives fully rendered — search engines see real content, not a loading spinner. The SvelteKit server calls the Rust API over the internal Docker network (http://backend:8080), so the browser never needs to reach the backend directly for page loads.

TipTap WYSIWYG Editor

The admin editor uses TipTap, which is built on ProseMirror. The key extensions:

  • StarterKit — headings, bold, italic, lists, blockquotes
  • CodeBlockLowlight — syntax-highlighted code blocks using lowlight with common language grammars
  • Image — inline images with drag-and-drop upload support
  • Link — URL linking with openOnClick: false for editing
  • Placeholder — ghost text when the editor is empty

The editor component wraps TipTap in a Svelte 5 component with a toolbar and fires an onUpdate callback with the HTML content, which gets stuffed into a hidden form field for submission.

Tailwind CSS 4

Styling uses Tailwind 4 with the @tailwindcss/typography plugin. The prose class handles all the blog post typography — headings, paragraphs, code blocks, lists — without writing a single CSS rule. The admin uses utility classes directly for the dashboard and editor UI.

Docker Compose: The Production Bugs

The deployment uses Docker Compose with two services and multi-stage builds. The Rust backend uses a rust:1 builder stage and runs on debian:bookworm-slim. The SvelteKit frontend builds with node:22-slim and runs with adapter-node.

This is where the real learning happened. Three bugs surfaced only in Docker:

Bug 1: SQLite Cannot Open Database File

The SqlitePoolOptions::connect(url) method does not create the database file. In development this was masked because the file already existed. In a fresh Docker volume, it crashed immediately. Fix: SqliteConnectOptions::from_str(url).create_if_missing(true).

Bug 2: Internal Docker Hostname in Browser Redirect

The OAuth flow redirected the browser to http://backend:8080/api/auth/github — but backend is a Docker internal hostname that the browser cannot resolve. The API_URL environment variable was being used for both server-to-server calls and browser redirects. Fix: separate API_URL (internal: http://backend:8080) from PUBLIC_API_URL (browser-facing: http://localhost:8080).

Bug 3: Secure Cookie Over HTTP

The OAuth callback set the auth cookie with secure: true, but the local development environment runs over plain HTTP. Browsers silently refuse to store secure cookies over HTTP — no error, no warning, just a cookie that vanishes into the void. The result was an infinite redirect loop: set cookie → redirect to admin → no cookie → redirect to GitHub → already authorized → redirect back → set cookie → repeat. Fix: secure: false for local development.

Bug 4: Non-Idempotent Migrations

CREATE INDEX without IF NOT EXISTS crashes on the second startup when the database file persists across container restarts. Fix: always use IF NOT EXISTS for migrations that run on every boot.

The Stack

ComponentTechnology
FrontendSvelteKit 2, Svelte 5, Tailwind 4
EditorTipTap (ProseMirror)
BackendRust, Axum 0.8, SQLx
DatabaseSQLite (WAL mode)
AuthGitHub OAuth + JWT
DeploymentDocker Compose

What I Would Do Differently

If I were starting over:

  • Use a proper migration toolsqlx migrate tracks which migrations have run instead of re-running everything on startup
  • Add a reverse proxy — Nginx or Caddy in front of both services eliminates the need for PUBLIC_API_URL and handles TLS
  • Environment-aware cookie settings — derive secure from the protocol instead of hardcoding
  • Add error pages — the OAuth 403 showed a raw browser error instead of a friendly message

But for a personal blog that took one session to build? It works, it is fast, and the Rust backend uses roughly 5MB of RAM. Good enough to ship.