Skip to content

Architecture Overview

This page is for engineers who want to understand Baan’s internals before customizing or extending it. It covers the directory layout, how a request flows from browser to database, the build pipeline, and the key abstractions you’ll encounter when working in the codebase.


baan/
├── app/ # Next.js App Router
│ ├── (public)/[locale]/ # Public-facing blog pages
│ │ ├── (blog)/ # Home, post, category, tag, search, entity pages
│ │ └── (pages)/ # Fixed pages
│ ├── baan-admin/
│ │ ├── setup/ # First-run setup wizard (unauthenticated)
│ │ └── [secretPath]/
│ │ ├── login/ # Login page
│ │ └── (authenticated)/ # All admin routes (require session)
│ │ ├── posts/
│ │ ├── settings/
│ │ ├── customizer/
│ │ ├── analytics/
│ │ └── knowledge-graph/
│ └── api/
│ ├── image/ # Image proxy
│ ├── kg/ # Public Knowledge Graph APIs
│ └── cms/v1/ # Headless CMS API
├── lib/ # Core library — no Next.js dependencies
│ ├── db/ # Database singleton, schema, migrations, stores
│ ├── settings/ # loadSettings() / saveSettings() and related helpers
│ ├── storage/ # Storage abstraction and provider implementations
│ ├── content/ # Posts, pages, taxonomy, metadata, search index
│ ├── theme/ # Theme manifest, loader, generator, customizer
│ ├── security/ # Auth-adjacent helpers, path, SSRF, rate limit, cookies
│ ├── ai/ # AI client, prompts, AEO score, bot/referrer analytics
│ └── knowledge-graph/ # Entity extraction, Wikidata, graph types
├── themes/ # Theme packages (one folder per theme)
│ ├── default/
│ ├── geek/
│ ├── monograph/
│ ├── news/
│ ├── slate/
│ ├── terminal/
│ ├── the-publish/
│ └── [your-theme]/
├── data/
│ └── app.db # SQLite database (self-hosted only)
├── content/ # Article files (local filesystem provider only)
├── scripts/ # Build-time and CLI scripts
│ ├── generate-theme-manifest.ts
│ ├── prebuild-site-settings.ts
│ └── migrate-to-turso.ts
├── .claude/
│ ├── CLAUDE.md # Project context for Claude Code
│ ├── rules/ # Auto-loaded rules per file path
│ └── skills/ # Slash commands (/create-theme, etc.)
└── __tests__/ # Test files (gitignored)

Browser → GET /en/posts/my-post-slug
Next.js App Router
└── app/(public)/[locale]/(blog)/posts/[slug]/page.tsx
├── reads active theme ID from settings
├── loads themes/[active]/components/post/PostContent.tsx
├── fetches post data from storage (Markdown → parsed)
├── fetches Knowledge Graph entities from DB
└── renders: Layout → PostContent → JSON-LD in <head>
Response: HTML (ISR-cached, revalidated on publish)
Browser → POST /baan-admin/api/posts
app/(admin)/baan-admin/api/posts/route.ts
├── auth wrapper — validates session cookie
├── validates request body
├── storagePutFile(...) — writes Markdown to storage
├── upserts article_index — updates metadata cache in DB
└── revalidatePath('/[slug]') — triggers ISR revalidation on Vercel
Response: { success: true, postId: '...' }
Browser → GET /api/cms/v1/posts?limit=10
app/api/cms/v1/posts/route.ts
├── withApiAuth(handler) — validates API key header
├── rate limit check
├── reads posts from storage
└── returns { data: [...], meta: { total, page, limit } }

Two prebuild scripts run automatically before every npm run dev and npm run build:

Scans themes/*/theme.json, validates each theme’s required files, and writes generated theme registries under lib/generated/:

lib/generated/theme-manifest.json
lib/generated/theme-components.ts
lib/generated/theme-fonts.ts

The theme loader reads theme-manifest.json and static component loaders from theme-components.ts to list available themes and validate the active theme ID. Adding or removing a theme folder requires regenerating the manifest and restarting the server.

Reads frequently-accessed settings from the database and writes them to a generated JSON file for zero-latency reads on hot paths (the public blog pages). This avoids a DB query on every page render for settings that rarely change.

Settings written to the generated file: active theme, site name, default locale, AIO configuration.


All content file I/O goes through the storage abstraction in lib/storage/. Feature code should use the exported helpers rather than direct fs access so it works across local, GCS, S3, and R2 providers.

// lib/storage/index.ts exports:
storagePutFile(path: string, content: string): Promise<void>
storageGetFile(path: string): Promise<{ exists: boolean; content: string | null }>
storageGetFileBuffer(path: string): Promise<Buffer>
storageListFiles(prefix: string): Promise<string[]>
storageDeleteFile(path: string): Promise<void>
storageDeleteFolder(prefix: string): Promise<void>

The active provider is configured via the setup wizard and stored in the database. Provider switching and content migration are handled in Admin → Settings → System.

If you add a feature that writes files (e.g., auto-generated OGP images), use storagePutFile rather than fs.writeFile. This ensures the feature works on both local and serverless deployments without code changes.


Baan uses libSQL/SQLite via @libsql/client. The connection is a singleton (getDB() in lib/db/index.ts) that returns the same client for the lifetime of the process.

-- User accounts
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
display_name TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Refresh tokens for session management
CREATE TABLE refresh_tokens (
token_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
issued_at INTEGER NOT NULL
);
-- Key-value store for all settings (JSON values)
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Signing secrets for session JWTs
CREATE TABLE session_secrets (
id INTEGER PRIMARY KEY CHECK (id = 1),
secret TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- Performance cache for article metadata; Markdown in storage remains the source of truth
CREATE TABLE article_index (
id TEXT NOT NULL,
article_id TEXT NOT NULL,
locale TEXT NOT NULL,
title TEXT NOT NULL,
date TEXT NOT NULL,
status TEXT NOT NULL,
category TEXT,
tags TEXT,
description TEXT,
excerpt TEXT,
cover_image TEXT,
entities TEXT,
synced_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (id, locale)
);
-- CMS API rate-limit counters
CREATE TABLE rate_limits (
key TEXT PRIMARY KEY,
count INTEGER NOT NULL,
reset_at TEXT NOT NULL
);

Settings are stored as JSON in the settings table. Access them via the settings helpers in lib/settings/storage.ts:

// Read
const siteSettings = await loadSettings('site') // → { name, description, ... }
const aiSettings = await loadSettings('ai') // → { provider, models, ... }
const security = await loadSettings('security') // → { adminUrlPath, allowedIPs, ... }
// Write
await saveSettings('site', { ...siteSettings, name: 'My Blog' })

Internally, loadSettings reads from the settings table and caches in memory. saveSettings writes to the table and invalidates the cache.

Never read from or write to the settings table with raw SQL in feature code. Always use loadSettings / saveSettings — this is enforced by .claude/rules/settings-storage.md.

Schema migrations run automatically on the first getDB() call. The migration logic is in lib/db/migrate.ts, and each migration checks the actual database state directly so it is safe to rerun.

To add a new table or column:

  1. Add or update the table definition in lib/db/schema.ts
  2. Add an idempotent migration function in lib/db/migrate.ts
  3. Migrations run automatically on the next getDB() call — no manual intervention needed

Baan’s settings are split by domain, each stored as a separate JSON value in the settings table:

KeyContents
siteSite name, description, default locale, author info
aiAI provider, model assignments, API key references
aiUsage / aiUsageDailyAI token usage tracking
ai-prompt-settingsAI prompt customization
aeoAI optimization settings such as llms.txt and schema controls
securityIP allowlist, bot management, Basic Auth config
cms-keysHeadless CMS and CLI API key management
taxonomyCategory and tag configuration

Convenience wrappers exist for frequently-accessed settings:

import { getAdminSecuritySettings } from '@/lib/security/admin'
// equivalent to: loadSettings('security') with type casting

Admin routes are protected by withAdminAuth — a wrapper that validates the session JWT from the HttpOnly cookie.

API routes use withApiAuth — validates the X-API-Key header against the stored API key hash.

Security configuration (IP allowlisting, Basic Auth, bot management) runs as a Next.js 16 request proxy in proxy.ts (the renamed middleware.ts introduced in Next.js 16) and executes on every request before routing.


See Creating Themes → Theme Architecture for the complete theme system documentation, including the registration pipeline, rendering flow, CSS variable cascade, and component isolation.


Baan ships with a pre-configured Claude Code environment designed for safe, consistent customization:

FilePurpose
CLAUDE.mdFull project context loaded at every session start
.claude/rules/settings-storage.mdEnforces loadSettings/saveSettings — no raw fs in app code
.claude/rules/theme-system.mdNever modify built-in themes; fork instead
.claude/rules/security.mdAuth requirements, what never to commit
.claude/rules/i18n.mdAll admin UI text through t(), never hardcoded
.claude/rules/cms-api.mdAPI response shape, auth, rate limiting
.claude/rules/database.mdgetDB() singleton, SQL injection prevention
.claude/rules/api-performance.mdPromise.all for parallel fetches
.claude/rules/error-handling.mdNo empty catch, consistent error shape

Rules load automatically based on which files are being edited. See Customizing with Claude Code for the full workflow.