Database
SvelteForge Admin uses SQLite as its database, accessed through Drizzle ORM with the better-sqlite3 driver. This gives your Svelte 5 and SvelteKit application a zero-configuration, high-performance database that lives as a single file in your project root.
Why SQLite
SQLite is the ideal database for SvelteKit admin dashboards and internal tools:
- Zero configuration — No database server to install, configure, or maintain.
Just a single file (
svelteforge.db) in your project root. - Simple local setup — Embedded SQLite needs no separate database service. With WAL mode enabled, concurrent reads never block each other.
- Perfect for deployment — Deploy your SvelteKit app with its database as a single unit. No connection strings to manage, no cold start latency.
- Type-safe with Drizzle — Drizzle ORM provides full TypeScript inference from your schema, catching query errors at compile time in your Svelte 5 components and SvelteKit server routes.
Connection Setup
The database connection is established in src/lib/server/db/index.ts. Because this
file lives inside SvelteKit's #lib/server/ directory, it is
guaranteed to never leak to the client bundle — a key security feature of SvelteKit's module system.
// src/lib/server/db/index.ts
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "./schema.js";
const dbPath = process.env.DATABASE_URL || "svelteforge.db";
const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = WAL");
export const db = drizzle(sqlite, { schema }); Key details:
- WAL journal mode — Write-Ahead Logging allows concurrent reads while a write is in progress. This is critical for SvelteKit apps where multiple server-side load functions may query simultaneously.
DATABASE_URLenv var — Defaults tosvelteforge.dbin the project root. Override it in production to point to a persistent volume.- Full schema import — Passing the entire schema to
drizzle()enables Drizzle's relational query API (db.query.users.findFirst(), etc.).
Schema Deep Dive
The complete database schema is defined in src/lib/server/db/schema.ts using Drizzle
ORM's SQLite table builder. Every table, column, type, constraint, and default is defined in
TypeScript — giving your Svelte 5 components and SvelteKit server routes full type inference.
Users Table
The users table stores all registered accounts with role-based access control. The
first user to register gets the admin role automatically.
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(),
name: text("name").notNull(),
avatarUrl: text("avatar_url"),
role: text("role", { enum: ["admin", "editor", "viewer"] })
.notNull()
.default("viewer"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
}); | Column | Type | Constraints | Notes |
|---|---|---|---|
id | text | PRIMARY KEY | Cryptographic random ID via generateId() |
email | text | NOT NULL, UNIQUE | Stored lowercase |
username | text | NOT NULL, UNIQUE | 3-31 chars, lowercase alphanumeric + hyphens/underscores |
passwordHash | text | NOT NULL | Argon2id hash |
name | text | NOT NULL | Display name |
avatarUrl | text | nullable | Profile image URL |
role | text enum | NOT NULL, default "viewer" | One of: admin, editor, viewer |
createdAt | integer (timestamp) | NOT NULL | Auto-set on insert, returns Date object |
updatedAt | integer (timestamp) | NOT NULL | Auto-set on insert, returns Date object |
Sessions Table
Session tokens are SHA-256 hashed before storage. The expiresAt column uses a raw
integer (milliseconds since epoch) instead of { mode: "timestamp" } for precise
expiry comparisons in the auth system.
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
expiresAt: integer("expires_at").notNull(),
userAgent: text("user_agent"),
ipAddress: text("ip_address"),
createdAt: integer("created_at", { mode: "timestamp" })
.$defaultFn(() => new Date()),
}); | Column | Type | Constraints | Notes |
|---|---|---|---|
id | text | PRIMARY KEY | SHA-256 hash of the session token |
userId | text | NOT NULL, FK → users.id | Owner of the session |
expiresAt | integer | NOT NULL | Raw milliseconds since epoch (no timestamp mode) |
userAgent | text | nullable | Browser/client user-agent string |
ipAddress | text | nullable | Client IP address |
createdAt | integer (timestamp) | nullable | Auto-set on insert |
Pages Table
The content management system stores pages with three templates and a status workflow. The authorId foreign key links each page to its creator in the users table.
export const pages = sqliteTable("pages", {
id: text("id").primaryKey(),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
content: text("content").notNull().default(""),
template: text("template", { enum: ["default", "landing", "blog"] })
.notNull()
.default("default"),
status: text("status", { enum: ["draft", "published", "archived"] })
.notNull()
.default("draft"),
authorId: text("author_id")
.notNull()
.references(() => users.id),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
publishedAt: integer("published_at", { mode: "timestamp" }),
}); | Column | Type | Constraints | Notes |
|---|---|---|---|
id | text | PRIMARY KEY | Cryptographic random ID |
title | text | NOT NULL | Page title |
slug | text | NOT NULL, UNIQUE | URL-friendly identifier |
content | text | NOT NULL, default "" | Page body content |
template | text enum | NOT NULL, default "default" | One of: default, landing, blog |
status | text enum | NOT NULL, default "draft" | One of: draft, published, archived |
authorId | text | NOT NULL, FK → users.id | Page author |
createdAt | integer (timestamp) | NOT NULL | Auto-set on insert |
updatedAt | integer (timestamp) | NOT NULL | Auto-set on insert |
publishedAt | integer (timestamp) | nullable | Set when status changes to published |
Notifications Table
Notifications can be user-specific or global (when userId is null). The SvelteKit app layout queries both types for the authenticated user's notification bell.
export const notifications = sqliteTable("notifications", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => users.id),
title: text("title").notNull(),
message: text("message").notNull(),
type: text("type", { enum: ["info", "warning", "error", "success"] })
.notNull()
.default("info"),
read: integer("read", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
}); | Column | Type | Constraints | Notes |
|---|---|---|---|
id | text | PRIMARY KEY | Cryptographic random ID |
userId | text | nullable, FK → users.id | Null = global notification for all users |
title | text | NOT NULL | Notification title |
message | text | NOT NULL | Notification body text |
type | text enum | NOT NULL, default "info" | One of: info, warning, error, success |
read | integer (boolean) | NOT NULL, default false | SQLite stores booleans as 0/1 integers |
createdAt | integer (timestamp) | NOT NULL | Auto-set on insert |
Password Reset Tokens Table
Tokens for the forgot-password flow. The token is SHA-256 hashed before storage (same pattern as sessions) and has a time-limited expiry.
export const passwordResetTokens = sqliteTable("password_reset_tokens", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
tokenHash: text("token_hash").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
}); | Column | Type | Constraints | Notes |
|---|---|---|---|
id | text | PRIMARY KEY | Cryptographic random ID |
userId | text | NOT NULL, FK → users.id | User requesting the reset |
tokenHash | text | NOT NULL | SHA-256 hash of the reset token |
expiresAt | integer (timestamp) | NOT NULL | Expiry time as Date object |
OAuth Accounts Table
Links external OAuth providers (Google, GitHub) to user accounts. A unique composite index on (provider, providerUserId) ensures no duplicate OAuth links.
export const oauthAccounts = sqliteTable(
"oauth_accounts",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
provider: text("provider", { enum: ["google", "github"] }).notNull(),
providerUserId: text("provider_user_id").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => [
uniqueIndex("oauth_provider_user_idx")
.on(table.provider, table.providerUserId)
]
); | Column | Type | Constraints | Notes |
|---|---|---|---|
id | text | PRIMARY KEY | Cryptographic random ID |
userId | text | NOT NULL, FK → users.id | Linked user account |
provider | text enum | NOT NULL | One of: google, github |
providerUserId | text | NOT NULL | User ID from the OAuth provider |
createdAt | integer (timestamp) | NOT NULL | Auto-set on insert |
Unique composite index: oauth_provider_user_idx on (provider, providerUserId) prevents the same OAuth account from being linked to multiple
users.
App Settings Table
A simple key-value store for application-wide configuration. The key column is the primary
key — no separate ID needed.
export const appSettings = sqliteTable("app_settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
}); | Column | Type | Constraints | Notes |
|---|---|---|---|
key | text | PRIMARY KEY | Setting name (e.g., "siteName", "maintenanceMode") |
value | text | NOT NULL | Setting value as string |
updatedAt | integer (timestamp) | NOT NULL | Auto-set on insert |
Timestamp Modes
Drizzle ORM's { mode: "timestamp" } option on integer columns controls how values
are serialized:
- With
mode: "timestamp"— Drizzle automatically converts between JavaScriptDateobjects and Unix timestamps. Used forcreatedAt,updatedAt, andpublishedAtacross most tables. - Without
mode: "timestamp"— The column stores and returns raw integers. Used forsessions.expiresAtwhich stores milliseconds since epoch for precise expiry comparisons without Date conversion overhead.
Type Exports
The schema file also exports inferred TypeScript types for use throughout the SvelteKit application:
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Session = typeof sessions.$inferSelect;
export type Page = typeof pages.$inferSelect;
export type NewPage = typeof pages.$inferInsert;
export type Notification = typeof notifications.$inferSelect;
export type OAuthAccount = typeof oauthAccounts.$inferSelect;
export type AppSetting = typeof appSettings.$inferSelect; ID Generation
All entity IDs in SvelteForge Admin are generated using cryptographic randomness, defined in src/lib/server/id.ts:
// src/lib/server/id.ts
import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding";
export function generateId(length: number = 15): string {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return encodeBase32LowerCaseNoPadding(bytes);
} - Cryptographically secure — Uses
crypto.getRandomValues()for truly random bytes. - Base32 encoded — Lowercase, no padding. URL-safe and case-insensitive.
- Default length: 15 bytes — Produces a 24-character string with 120 bits of
entropy. The seed script uses
generateId(10)for shorter IDs.
Migrations & Schema Changes
SvelteForge Admin uses Drizzle Kit for database migrations. The configuration
lives in drizzle.config.ts:
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/lib/server/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: "svelteforge.db",
},
}); Available Commands
| Command | Purpose |
|---|---|
pnpm db:push | Push schema changes directly to the database (development) |
pnpm db:generate | Generate SQL migration files in the ./drizzle directory |
pnpm db:studio | Open Drizzle Studio GUI in your browser to inspect/edit data |
pnpm db:seed | Seed the database with sample data |
Step-by-Step: Changing the Schema
- Edit the schema — Modify
src/lib/server/db/schema.ts(add columns, tables, constraints). - Push changes — Run
pnpm db:pushto apply changes directly to your local SQLite database. - Update test utilities — If you have tests, update the
SCHEMA_SQLconstant intest-utils.tsto match the new schema. Tests use an in-memory SQLite database that is created from this SQL string. - Generate migrations (optional) — For production deployments, run
pnpm db:generateto create versioned SQL migration files.
Database Seeding
The seed script at src/lib/server/db/seed.ts populates the database with realistic sample
data. Run it with:
pnpm db:seed What Gets Created
| Entity | Count | Details |
|---|---|---|
| Users | 50 | Realistic 12-month growth curve (2 users/month early, 7 users/month recent). Mix of admin, editor, and viewer roles. |
| Pages | 65 | Across all templates (default, landing, blog) and statuses. Earlier months mostly published, recent months more drafts. |
| Notifications | 33 | Mix of info, warning, error, and success types. Older ones marked as read, recent ones unread. |
| App Settings | 4 | siteName, timezone, defaultRole, maintenanceMode |
Key Details
- All passwords:
password123— Hashed with Argon2id (memoryCost: 19456, timeCost: 2, outputLen: 32, parallelism: 1). - Login with:
[email protected]/password123(or any seeded user withpassword123). - Realistic timestamps: Uses a
daysAgo()helper to create dates spread across the past 12 months. - Runs outside SvelteKit: The seed script is executed via the locally installed
tsx, not through SvelteKit's Vite server. This means it uses relative imports (./index.js,../id.js) instead of#lib/aliases.
Drizzle Studio
Drizzle Studio provides a browser-based GUI for inspecting and editing your SQLite database. Launch it with:
pnpm db:studio This opens a visual interface where you can browse tables, run queries, edit rows, and inspect relationships — useful during development of your Svelte 5 components and SvelteKit server routes.
Querying Patterns
Here are common Drizzle ORM query patterns used throughout the SvelteKit server routes in SvelteForge Admin.
Select All Records
import { db } from "#lib/server/db/index.js";
import { users } from "#lib/server/db/schema.js";
// Select specific columns
const allUsers = await db
.select({
id: users.id,
name: users.name,
email: users.email,
role: users.role,
createdAt: users.createdAt,
})
.from(users)
.orderBy(users.createdAt); Find One Record
import { eq } from "drizzle-orm";
// Using the relational query API
const user = await db.query.users.findFirst({
where: eq(users.email, "[email protected]"),
}); Insert a Record
import { generateId } from "#lib/server/id.js";
await db.insert(users).values({
id: generateId(10),
email: "[email protected]",
username: "newuser",
passwordHash: hashedPassword,
name: "New User",
role: "viewer",
}); Update a Record
import { eq } from "drizzle-orm";
await db
.update(users)
.set({ role: "editor", updatedAt: new Date() })
.where(eq(users.id, userId)); Delete Records
import { eq } from "drizzle-orm";
await db.delete(users).where(eq(users.id, userId)); Aggregation with SQL
import { sql, eq, and, or, isNull } from "drizzle-orm";
// Count unread notifications for a user (including global ones)
const [result] = await db
.select({ count: sql<number>`count(*)` })
.from(notifications)
.where(
and(
eq(notifications.read, false),
or(
eq(notifications.userId, userId),
isNull(notifications.userId)
)
)
); Pattern Matching (LIKE)
import { sql, or } from "drizzle-orm";
const pattern = `%${searchQuery}%`;
const results = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(or(
sql`${users.name} LIKE ${pattern}`,
sql`${users.email} LIKE ${pattern}`
))
.limit(10); Need More?
Go Premium with DashboardPack
SvelteForge Admin demonstrates a solid SQLite + Drizzle ORM setup for Svelte 5 and SvelteKit. Need more advanced database patterns? DashboardPack premium templates include multi-tenant schemas, advanced CRUD interfaces with pagination/filtering/sorting, data import/export, and production-grade database management UIs.
- Apex (Svelte) — SvelteKit admin with 6 dashboards, advanced data tables, and full CRUD operations
- Zenith — Analytics dashboard with complex aggregation queries and real-time data
- Signal — Monitoring dashboard with database health metrics and query performance tracking