Why better-auth for SvelteKit

Every serious web application eventually confronts authentication. The ecosystem has no shortage of options as Lucia, Auth.js, Clerk, Supabase Auth where each comes with its own opinion about where sessions live, how the database schema looks and how tightly it couples to your framework. better-auth takes a refreshingly practical position: it is framework-aware but not framework-locked, generates its schema as plain SQL via a CLI, exposes a clean TypeScript API and ships a SvelteKit plugin that wires cookies into SvelteKit’s response model correctly.

One thing better-auth does not demand is an ORM. It works directly with raw database clients and manages its own queries internally. You give it a connection; it handles everything auth-related from there. Your own domain queries remain yours to write however you prefer.

This article uses better-sqlite3 as the concrete database driver, it requires zero configuration, runs in-process, and is the fastest way to get a working setup in front of you. The database layer is deliberately a swap-out zone: if you use PostgreSQL, MySQL, or Turso, only that section changes. Every other piece, the hook, the client, the UI, the context wiring, is identical regardless of which database sits underneath.

By the end you will have a complete, working authentication system built in this exact sequence:

StepWhat it doesFile(s)
1Install packages and approve the native build
2Set environment variables.env
3Open the database connectionsrc/lib/server/db.ts
4Configure better-authsrc/lib/server/auth.ts
5Declare TypeScript localssrc/app.d.ts
6Generate the database schemanpx @better-auth/cli
7Apply the schemasqlite3 or db.exec()
8Mount the catch-all API routesrc/routes/api/auth/[...all]/+server.ts
9Validate sessions in the hooksrc/hooks.server.ts
10Create the browser clientsrc/lib/client/auth.ts
11Build the login pagesrc/routes/login/+page.svelte
12Build the sign-up pagesrc/routes/register/+page.svelte
13Build the logout buttonsrc/lib/components/LogoutButton.svelte
14Wire locals.user into contextsrc/routes/+layout.server.ts + +layout.svelte
15Build the dashboard pagesrc/routes/dashboard/+page.svelte

Each step creates something the next step depends on. The sequence matters, in particular, auth.ts must exist before you run the CLI, and app.d.ts must declare locals before the hook assigns to them. Follow the steps in order and you will not hit avoidable errors.

Once locals.user is populated it slots directly into the createAuthContext() pattern covered in Authentication Architecture with Context. That article explains why context is the right distribution mechanism. This one explains how to feed it real session data.


The Problem Space

Rolling your own authentication is one of the most reliable ways to introduce security vulnerabilities. Password hashing, session token generation, CSRF protection, cookie attributes (HttpOnly, Secure, SameSite), token rotation ,each is a subtle, load-bearing piece. Getting any one of them wrong can quietly expose your users.

At the same time, many “fully managed” auth solutions take control away from you at exactly the wrong moments. They store sessions in their cloud, enforce their schema, and make it awkward to inspect a session record in your own database or run a targeted migration.

better-auth targets the middle ground: you own the database, you own the schema, you own the deployment. The library handles the cryptography and the session lifecycle. Your job is wiring it into SvelteKit’s request/response model, which turns out to be surprisingly minimal once you see the individual pieces.


Step 1 — Install Packages

pnpm add better-auth better-sqlite3
pnpm add -D @types/better-sqlite3

That is the complete install. Two packages, nothing else.

After the install completes, pnpm will print a warning:

Ignored build scripts: better-sqlite3.
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.

This is expected and it is blocking, you must resolve it before continuing. better-sqlite3 is a native Node.js addon that compiles a small C++ binary during installation. Without that binary the package simply does not work. pnpm’s security policy blocks dependency build scripts by default until you explicitly approve them:

pnpm approve-builds better-sqlite3

pnpm writes the approval to your project’s .npmrc so it persists across future installs and is safe to commit to your repository. Running pnpm approve-builds without an argument opens an interactive selector if you prefer to review all pending approvals at once.

One package to specifically not add here: @better-auth/cli. The CLI is needed in Step 6 to generate the database schema, but it should not be installed as a project dependency. @better-auth/cli lists @prisma/client as an optional peer, and pnpm’s auto-install-peers setting resolves optional peers eagerly by default, meaning installing the CLI silently pulls Prisma into your project.

Use npx instead, which fetches and runs the CLI in an isolated temporary location without touching your node_modules. When you run the npx command in Step 6 for the first time, you will see a prompt like this:

Need to install the following packages:
  @better-auth/cli@x.x.x
Ok to proceed? (y)

This is normal - type y and press Enter to continue. npx caches the download, so the prompt only appears once.


Step 2 — Environment Variables

better-auth needs two values at runtime that belong in your .env file:

# .env

# A long random string used to sign and verify session tokens.
# Generate a good one with: openssl rand -base64 32
BETTER_AUTH_SECRET="replace_with_a_strong_random_secret"

# The base URL your application is served from.
# In production, set this to your actual domain.
BETTER_AUTH_URL="http://localhost:5173"

# Also add this - the browser client needs the base URL too,
# and it must use the PUBLIC_ prefix to be accessible in browser code.
PUBLIC_BASE_URL="http://localhost:5173"

BETTER_AUTH_SECRET is the most security-sensitive value in this setup. better-auth uses it to sign session tokens cryptographically, anyone who has the secret can forge a valid session token without touching the database. Treat it like a root database password: never commit it, rotate it if it leaks, and inject it from a secrets manager in production.

BETTER_AUTH_URL tells better-auth where the application lives so it can construct absolute URLs. Password-reset emails, OAuth redirect URIs, and any other URL better-auth needs to build all derive from this value.

PUBLIC_BASE_URL serves the same purpose for the browser client. The PUBLIC_ prefix is SvelteKit’s convention for environment variables that are safe to include in browser bundles. Variables without this prefix are server-only and cannot be imported in client code.

SvelteKit exposes server-only variables through $env/static/private and public variables through $env/static/public. The static in both means the values are inlined at build time; the bundler refuses to include private variables in any code that could reach the browser.


Step 3 — Open the Database Connection

Create a module that opens a single better-sqlite3 connection for the server process. This is the only database-specific file in the entire integration, everything else is the same regardless of which database you use.

// src/lib/server/db.ts

import Database from 'better-sqlite3'
import { join } from 'node:path'

// Open a single connection for the entire server process to share.
export const db = new Database(join(process.cwd(), 'db/local.db'))

// Enable WAL mode for better concurrency during development.
db.pragma('journal_mode = WAL')

Why WAL mode

WAL (Write-Ahead Logging) allows concurrent reads during writes. better-auth performs a session lookup on every authenticated request, so without WAL a write operation — a sign-in, a session refresh — would briefly block all reads.

One thing worth knowing: db.pragma('journal_mode = WAL') does not run at server startup. SvelteKit lazy-loads server modules, so db.ts is only imported the first time a request needs it — when hooks.server.ts runs for an incoming request. This means local.db-wal and local.db-shm will not appear on disk until you open a page in the browser for the first time after starting the dev server. That is expected behaviour. Running sqlite3 db/local.db < db/schema.sql alone will never produce the sidecar files — only the running application can.

Why process.cwd() instead of a relative path

A bare relative string like 'db/local.db' resolves against whatever directory the Node process was started from. When SvelteKit runs the dev server that is always the project root, so it works. But when the better-auth CLI imports this file via npx, it may use a different working directory and silently land the database file in the wrong place. process.cwd() is always anchored to the project root when invoked through pnpm scripts or npx run from the project directory, so join(process.cwd(), 'db/local.db') is safe in both contexts.

Add the database files to .gitignore — the directory itself stays tracked since schema.sql lives there too:

echo 'db/local.db' >> .gitignore
echo 'db/local.db-wal' >> .gitignore
echo 'db/local.db-shm' >> .gitignore
Using a Different Database?

This is the only file that changes when you move to PostgreSQL, MySQL, or Turso. better-auth ships first-party adapters for each. Replace the database: db line in auth.ts (Step 4) with the appropriate adapter, everything else in this article stays the same.

DatabaseDriverHow to pass to betterAuth()
SQLitebetter-sqlite3database: db - pass directly, no adapter needed
Turso / libSQL@libsql/clientdatabase: libsqlAdapter(client) from better-auth/adapters/libsql
PostgreSQLpg / postgresdatabase: pgAdapter(pool) from better-auth/adapters/pg
MySQLmysql2database: mysqlAdapter(conn) from better-auth/adapters/mysql
MongoDBmongodbdatabase: mongodbAdapter(client) from better-auth/adapters/mongodb

Every step after Step 3 is identical regardless of which adapter you choose. The full setup for each common alternative is below — beginners on SQLite can skip ahead to Step 4.

PostgreSQL with pg

pnpm add pg better-auth
pnpm add -D @types/pg
// src/lib/server/db.ts
import pg from 'pg'

// A connection pool — pg manages multiple connections automatically.
// Pool size defaults to 10; tune with max: N if needed.
export const db = new pg.Pool({
	connectionString: process.env.DATABASE_URL
	// In production, always require SSL:
	// ssl: { rejectUnauthorized: true },
})
// src/lib/server/auth.ts — only the database line changes
import { pgAdapter } from 'better-auth/adapters/pg'
import { db } from '$lib/server/db.js'

export const auth = betterAuth({
	database: pgAdapter(db)
	// ... everything else stays the same
})
# .env
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

Turso (libSQL) — SQLite at the Edge

Turso is a distributed SQLite service. Same SQL dialect as better-sqlite3, but runs as a managed remote or embedded-replica database — well suited for edge deployments and multi-region setups.

pnpm add @libsql/client better-auth
// src/lib/server/db.ts
import { createClient } from '@libsql/client'

export const db = createClient({
	url: process.env.TURSO_DATABASE_URL!,
	authToken: process.env.TURSO_AUTH_TOKEN
})
// src/lib/server/auth.ts — only the database line changes
import { libsqlAdapter } from 'better-auth/adapters/libsql'
import { db } from '$lib/server/db.js'

export const auth = betterAuth({
	database: libsqlAdapter(db)
	// ... everything else stays the same
})
# .env
TURSO_DATABASE_URL="libsql://your-db.turso.io"
TURSO_AUTH_TOKEN="your-turso-token"

For local development without a Turso account, point it at a local file instead:

export const db = createClient({ url: 'file:db/local.db' })

MySQL with mysql2

pnpm add mysql2 better-auth
// src/lib/server/db.ts
import mysql from 'mysql2/promise'

// createPool returns a promise-based pool compatible with
// better-auth's MySQL adapter.
export const db = mysql.createPool({
	uri: process.env.DATABASE_URL
	// waitForConnections: true,
	// connectionLimit: 10,
})
// src/lib/server/auth.ts — only the database line changes
import { mysqlAdapter } from 'better-auth/adapters/mysql'
import { db } from '$lib/server/db.js'

export const auth = betterAuth({
	database: mysqlAdapter(db)
	// ... everything else stays the same
})
# .env
DATABASE_URL="mysql://user:password@localhost:3306/mydb"

Step 4 — Configure better-auth

Now that a database connection exists, create the central auth configuration. Every other piece of the integration either feeds into this object or reads from it.

better-auth has no built-in authentication strategy - nothing is enabled by default. You opt in to each strategy explicitly via the plugins array. This keeps the bundle lean: the library only ships code for what you actually use. The base configuration sets up the security primitives (secret, base URL, database connection, SvelteKit cookie handling) and then you layer authentication strategies on top.

Base Configuration

Start with the minimal wiring that every setup requires regardless of which auth strategy you choose:

// src/lib/server/auth.ts

import { betterAuth } from 'better-auth'
import { sveltekitCookies } from 'better-auth/svelte-kit'
import { getRequestEvent } from '$app/server'
import { db } from '$lib/server/db.js'
import { BETTER_AUTH_SECRET, BETTER_AUTH_URL } from '$env/static/private'

export const auth = betterAuth({
	secret: BETTER_AUTH_SECRET,
	baseURL: BETTER_AUTH_URL,

	// Pass the better-sqlite3 connection directly.
	// better-auth detects the driver and manages all auth-related
	// queries itself - you never write SQL for the auth tables.
	database: db,

	plugins: [
		// Teaches better-auth to write session cookies using SvelteKit's
		// event.cookies.set() rather than raw Set-Cookie headers.
		// getRequestEvent is required - better-auth uses it to access
		// the active RequestEvent and route cookie writes through
		// event.cookies.set() rather than raw Set-Cookie headers.
		//
		// sveltekitCookies must always be the LAST plugin in the array.
		sveltekitCookies(getRequestEvent)
	]
})

// Infer TypeScript types directly from the configuration object.
// These update automatically when you add or remove plugins —
// no manual type maintenance required.
export type Session = typeof auth.$Infer.Session
export type User = typeof auth.$Infer.Session.user

sveltekitCookies(getRequestEvent) is the only non-negotiable plugin. SvelteKit’s response model requires cookies to be written through event.cookies.set(). If better-auth writes a Set-Cookie header directly on the Response object, which is standard HTTP behaviour outside SvelteKit, SvelteKit silently drops it. The session cookie never reaches the browser, sign-in appears to complete successfully on the server, but the user is unauthenticated on the very next request with no error message to explain why.

getRequestEvent is imported from $app/server and gives the plugin access to the active SvelteKit RequestEvent at call time, specifically the cookies object it needs. This argument became required in a later release of better-auth; calling sveltekitCookies() without it is both a TypeScript error and a silent runtime failure.

One more constraint that is easy to miss: sveltekitCookies must always be the last plugin in the array. better-auth runs plugins in order, and sveltekitCookies intercepts cookie operations from whichever plugins ran before it. If another plugin is listed after it, that plugin’s cookie writes happen after the interception point and are silently dropped.

Choosing an Authentication Strategy

With the base config in place, add one or more authentication strategy plugins to the plugins array. Each strategy is an independent import, add only what your application needs.

Email and password is the most common starting point. emailAndPassword is a top-level config option on betterAuth(), not a plugin. Add it alongside the base config:

export const auth = betterAuth({
	secret: BETTER_AUTH_SECRET,
	baseURL: BETTER_AUTH_URL,
	database: db,

	// Top-level option, not a plugin. Enables signIn.email() and signUp.email()
	// on the browser client. better-auth hashes passwords with bcrypt internally.
	emailAndPassword: { enabled: true },

	plugins: [
		sveltekitCookies(getRequestEvent) // must be last
	]
})

OAuth (social login) authenticates via an external provider — GitHub, Google, Discord, and others. No local password is stored. Configure providers via the top-level socialProviders object, then add the credentials to .env:

export const auth = betterAuth({
	// ...base config...
	socialProviders: {
		github: { clientId: GITHUB_CLIENT_ID, clientSecret: GITHUB_CLIENT_SECRET },
		google: { clientId: GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET }
	},
	plugins: [sveltekitCookies(getRequestEvent)]
})

Magic link sends a one-time sign-in URL to the user’s email — no password stored. It lives in the plugins array (before sveltekitCookies) and needs an email transport:

import { magicLink } from 'better-auth/plugins'

export const auth = betterAuth({
	// ...base config...
	plugins: [
		magicLink({
			sendMagicLink: async ({ email, url }) => {
				await sendEmail({ to: email, subject: 'Sign in', html: `<a href="${url}">Sign in</a>` })
			}
		}),
		sveltekitCookies(getRequestEvent) // must be last
	]
})

Combining strategies is straightforward since emailAndPassword and socialProviders are both top-level options and coexist freely. The rest of this article uses email/password as the concrete example. Everything from Step 5 onwards is identical regardless of which strategy you choose.


Step 5 — Declare TypeScript Locals

Before writing the hook that assigns to event.locals, you need to tell TypeScript what event.locals contains. SvelteKit uses a global ambient declaration for this in src/app.d.ts - without it, TypeScript does not know the user and session properties exist, and the hook will produce type errors.

// src/app.d.ts

import type { User, Session } from '$lib/server/auth.js'

declare global {
	namespace App {
		interface Locals {
			// The authenticated user, or null if there is no valid session.
			// Set by hooks.server.ts before any load function runs.
			user: User | null

			// The raw session record from better-auth.
			// Useful if you need session metadata such as expiry or IP address.
			session: Session | null
		}
	}
}

export {}

The User and Session types come from the inferred exports you created in Step 4. This keeps everything in sync, if better-auth changes the user shape when you add a plugin, the change flows automatically through the inferred type into app.d.ts and into every load function that reads locals.user.


Step 6 — Generate the Database Schema

better-auth needs four tables: user, session, account, and verification. The CLI generates them as plain SQL based on your auth.ts configuration. This is the correct point to run it, the config must exist first, which is why it was Step 4 and this is Step 6.

Run the CLI with npx. The --config flag is required because the CLI searches for auth.ts in the project root by default and will not find yours inside src/lib/server/ without being told where to look:

mkdir -p db
npx @better-auth/cli generate --config src/lib/server/auth.ts --output db/schema.sql

The -p flag on mkdir makes the command a no-op if db/ already exists, so it is safe to run every time. If this is your first time running the command, npx will prompt you to confirm the download, type y to proceed. The CLI is not installed into your project; it runs from a temporary cache. See Step 1 for why npx is preferred over installing the CLI as a dependency.

The generated schema.sql for SQLite looks like this:

-- schema.sql
-- Generated by @better-auth/cli
-- Re-run this command whenever you add or remove plugins.

CREATE TABLE IF NOT EXISTS "user" (
  "id"            TEXT NOT NULL PRIMARY KEY,
  "name"          TEXT NOT NULL,
  "email"         TEXT NOT NULL UNIQUE,
  "emailVerified" INTEGER NOT NULL,
  "image"         TEXT,
  "createdAt"     INTEGER NOT NULL,
  "updatedAt"     INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS "session" (
  "id"        TEXT NOT NULL PRIMARY KEY,
  "expiresAt" INTEGER NOT NULL,
  "token"     TEXT NOT NULL UNIQUE,
  "createdAt" INTEGER NOT NULL,
  "updatedAt" INTEGER NOT NULL,
  "ipAddress" TEXT,
  "userAgent" TEXT,
  "userId"    TEXT NOT NULL REFERENCES "user"("id") ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS "account" (
  "id"                    TEXT NOT NULL PRIMARY KEY,
  "accountId"             TEXT NOT NULL,
  "providerId"            TEXT NOT NULL,
  "userId"                TEXT NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
  "accessToken"           TEXT,
  "refreshToken"          TEXT,
  "idToken"               TEXT,
  "accessTokenExpiresAt"  INTEGER,
  "refreshTokenExpiresAt" INTEGER,
  "scope"                 TEXT,
  "password"              TEXT,
  "createdAt"             INTEGER NOT NULL,
  "updatedAt"             INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS "verification" (
  "id"         TEXT NOT NULL PRIMARY KEY,
  "identifier" TEXT NOT NULL,
  "value"      TEXT NOT NULL,
  "expiresAt"  INTEGER NOT NULL,
  "createdAt"  INTEGER,
  "updatedAt"  INTEGER
);

The account table is where hashed passwords live when using the emailAndPassword plugin. The verification table handles email verification tokens and password-reset tokens. better-auth manages all reads and writes to these tables, you never query them directly.

Re-run the CLI After Adding Plugins
The generated schema reflects your current plugin configuration. Adding `twoFactor()` requires a `twoFactor` table. Adding `organization()` requires several new tables. Re-run the CLI after changing your plugin list, then apply the new SQL.

To ensure the CLI version matches your installed better-auth version, pin it explicitly:

npx @better-auth/cli@1.x.x generate --config src/lib/server/auth.ts --output db/schema.sql

Replace 1.x.x with your installed version, check node_modules/better-auth/package.json. Mismatched versions can generate schema that does not match what the library expects at runtime.


Step 7 — Apply the Schema

Apply the generated SQL to your SQLite database:

sqlite3 db/local.db < db/schema.sql

db/local.db will be created if it does not already exist. The IF NOT EXISTS clause in every CREATE TABLE statement makes this command safe to re-run, applying the schema against a database that already has the tables is a no-op.

If you want the database to bootstrap itself automatically on startup, useful so a fresh clone just works without a manual step, you can apply the schema from db.ts instead:

// src/lib/server/db.ts

import Database from 'better-sqlite3'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'

export const db = new Database(join(process.cwd(), 'db/local.db'))
db.pragma('journal_mode = WAL')

// Apply the generated schema on every startup.
// IF NOT EXISTS makes this safe to run against an existing database.
const schema = readFileSync(join(process.cwd(), 'db/schema.sql'), 'utf-8')
db.exec(schema)

Both approaches work. The sqlite3 command is explicit and easy to audit; the startup approach is convenient for development. In production with a real database you would typically apply schema changes through a proper migration runner with versioned files.

Resetting the Database

During development you will occasionally need a clean slate — to test registration with a fresh database, clear out test accounts, or recover from a corrupted state. Delete all three SQLite files and re-apply the schema:

rm db/local.db db/local.db-wal db/local.db-shm
sqlite3 db/local.db < db/schema.sql

The WAL sidecar files (local.db-wal and local.db-shm) must be deleted alongside the main file. Leaving them orphaned causes SQLite errors on the next startup because they reference a transaction log for a database file that no longer exists.

If you are using the startup bootstrap approach (db.exec(schema) in db.ts), skip the second command — just delete the files and restart the dev server. The schema is re-applied automatically on the next start.


Step 8 — Mount the Catch-All API Route

better-auth handles all of its own endpoints, sign-in, sign-out, sign-up, session refresh, email verification, through a single request handler. Mount it at the conventional location:

// src/routes/api/auth/[...all]/+server.ts

import { auth } from '$lib/server/auth.js'
import type { RequestHandler } from './$types'

export const GET: RequestHandler = (event) => {
	return auth.handler(event.request)
}

export const POST: RequestHandler = (event) => {
	return auth.handler(event.request)
}

If TypeScript reports Cannot find module './$types', the SvelteKit type generation has not run yet. SvelteKit generates $types files automatically when the dev server starts or when you run sync explicitly:

pnpm exec svelte-kit sync

Run this once, the error disappears and RequestHandler resolves correctly. The dev server runs sync on every start, so in normal development you will only hit this when creating files before starting the server for the first time.

The [...all] dynamic segment captures every path under /api/auth/. When the browser sends POST /api/auth/sign-in/email, SvelteKit routes it to this file and auth.handler dispatches it to the email/password sign-in logic. When the browser sends POST /api/auth/sign-out, the same handler routes it to sign-out. You never write individual routes for individual auth operations, the handler covers all of them, including any operations added by future plugins.

The handler returns a standard Response. The sveltekitCookies() plugin from Step 4 intercepts the session cookie write inside the handler and routes it through event.cookies.set() before the response is returned, which is why that plugin was required.

The svelteKitHandler Alternative

better-auth’s own documentation shows a different integration path using svelteKitHandler from better-auth/svelte-kit. That convenience function combines Steps 8 and 9 into a single hook call — it handles both auth traffic routing and session population:

// hooks.server.ts — the single-hook approach from better-auth's docs
import { svelteKitHandler } from 'better-auth/svelte-kit'
import { auth } from '$lib/server/auth.js'

export const handle = ({ event, resolve }) => svelteKitHandler({ event, resolve, auth })

With this approach you do not need the [...all]/+server.ts catch-all route at all. svelteKitHandler intercepts requests to /api/auth/* directly inside the hook, and also populates event.locals.session and event.locals.user automatically — replacing the explicit getSession call in Step 9.

This article separates the two concerns intentionally. Seeing the catch-all route and the session validation as distinct pieces makes it easier to understand what each one does and where things can go wrong. Once you are comfortable with the underlying wiring, svelteKitHandler is a cleaner production setup and is what better-auth’s own documentation recommends.


Step 9 — Validate Sessions in the Hook

The hook is where every request picks up its authenticated identity. It runs before any load function or form action in your application, which means by the time your pages execute, event.locals.user is already resolved.

// src/hooks.server.ts

import { auth } from '$lib/server/auth.js'
import type { Handle } from '@sveltejs/kit'

export const handle: Handle = async ({ event, resolve }) => {
	const session = await auth.api.getSession({
		headers: event.request.headers
	})

	// Always assign both locals. null means unauthenticated —
	// which is a valid and expected state, not missing data.
	event.locals.user = session?.user ?? null
	event.locals.session = session?.session ?? null

	return resolve(event)
}

auth.api.getSession reads the session cookie from the request headers, validates the token cryptographically against BETTER_AUTH_SECRET, checks that the session has not expired, and returns the full session and user objects, or null if anything fails validation. The entire process is local: your SQLite file, your secret, no external network call.

event.locals is request-scoped. SvelteKit creates a fresh locals object for every incoming request and discards it after the response is sent. There is no shared mutable state between requests, no risk of one user’s data leaking into another user’s request, and no SSR contamination.

With locals.user populated by the hook, protecting a route inside a load function is a single check:

// src/routes/dashboard/+page.server.ts

import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals }) => {
	if (!locals.user) {
		// This redirect happens before SvelteKit renders any HTML.
		// The user never receives a flash of protected content.
		redirect(303, '/login')
	}

	return { user: locals.user }
}

Step 10 — Create the Browser Client

The auth object from src/lib/server/auth.ts cannot be imported in browser code, it holds a database connection that does not exist in the browser, and SvelteKit enforces the $lib/server/ boundary at build time. For browser-side operations, better-auth provides a separate typed client that communicates with the server through HTTP:

// src/lib/client/auth.ts

// Import from 'better-auth/svelte', not 'better-auth/client'.
// The Svelte-specific client adds useSession() as a reactive Svelte store,
// so session state integrates natively with Svelte's reactivity model.
// The generic 'better-auth/client' works but gives you a plain object
// that does not update components automatically.
import { createAuthClient } from 'better-auth/svelte'
import { PUBLIC_BASE_URL } from '$env/static/public'

export const authClient = createAuthClient({
	baseURL: PUBLIC_BASE_URL ?? 'http://localhost:5173'
})

export type AuthClientSession = typeof authClient.$Infer.Session

No plugins are needed in the client for email/password. The signIn.email() and signUp.email() methods are available by default because emailAndPassword was enabled on the server in Step 4. Client plugins are only required for server plugins that extend the API surface, for example, adding twoFactor() on the server means adding twoFactorClient() to the client plugins array here so the two-factor methods are available and correctly typed.

Placing the client in src/lib/client/ rather than src/lib/server/ is intentional. The server/ boundary is enforced at build time, any attempt to import a $lib/server/ module into browser code is a compile error. The client only makes fetch calls and belongs outside that boundary.


Step 11 — Build the Login Page

With all the infrastructure in place, sign-in is straightforward Svelte 5 form handling:

<!-- src/routes/login/+page.svelte -->
<script lang="ts">
	import { authClient } from '$lib/client/auth.js'
	import { goto, invalidateAll } from '$app/navigation'

	let email = $state('')
	let password = $state('')
	let error = $state<string | null>(null)
	let submitting = $state(false)

	async function handleSubmit(event: Event) {
		event.preventDefault()
		error = null
		submitting = true

		const result = await authClient.signIn.email({ email, password })

		submitting = false

		if (result.error) {
			error = result.error.message ?? 'Sign in failed. Please try again.'
			return
		}

		// The session cookie has been written by the server.
		// invalidateAll() forces the layout load to re-run so locals.user
		// is picked up by the hook and page.data.user updates before
		// navigating. Without this, the context still holds null from
		// before sign-in and the dashboard shows the unauthenticated state.
		await invalidateAll()
		goto('/dashboard')
	}
</script>

<div class="page">
	<div class="card">
		<div class="card-header">
			<h1 class="card-title">Sign in</h1>
			<p class="card-description">Enter your email and password to continue.</p>
		</div>

		{#if error}
			<div class="alert" role="alert">{error}</div>
		{/if}

		<form class="form" onsubmit={handleSubmit}>
			<div class="field">
				<label class="label" for="email">Email</label>
				<input
					id="email"
					class="input"
					type="email"
					bind:value={email}
					autocomplete="email"
					placeholder="you@example.com"
					required
				/>
			</div>

			<div class="field">
				<label class="label" for="password">Password</label>
				<input
					id="password"
					class="input"
					type="password"
					bind:value={password}
					autocomplete="current-password"
					placeholder="••••••••"
					required
				/>
			</div>

			<button class="btn-primary" type="submit" disabled={submitting}>
				{submitting ? 'Signing in…' : 'Sign in'}
			</button>
		</form>

		<p class="footer-link">
			Don't have an account? <a href="/register">Create one</a>
		</p>
	</div>
</div>

<style>
	.page {
		min-height: 100vh;
		display: flex;
		align-items: center;
		justify-content: center;
		padding: 1rem;
		background-color: hsl(0 0% 98%);
	}

	.card {
		width: 100%;
		max-width: 400px;
		background: hsl(0 0% 100%);
		border: 1px solid hsl(240 5.9% 90%);
		border-radius: 0.5rem;
		padding: 2rem;
		box-shadow: 0 1px 3px hsl(0 0% 0% / 0.06);
	}

	.card-header {
		margin-bottom: 1.5rem;
	}

	.card-title {
		font-size: 1.25rem;
		font-weight: 600;
		color: hsl(240 10% 3.9%);
		margin: 0 0 0.25rem;
	}

	.card-description {
		font-size: 0.875rem;
		color: hsl(240 3.8% 46.1%);
		margin: 0;
	}

	.alert {
		background: hsl(0 86% 97%);
		border: 1px solid hsl(0 72% 88%);
		color: hsl(0 72% 40%);
		border-radius: 0.375rem;
		padding: 0.75rem 1rem;
		font-size: 0.875rem;
		margin-bottom: 1rem;
	}

	.form {
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}

	.field {
		display: flex;
		flex-direction: column;
		gap: 0.375rem;
	}

	.label {
		font-size: 0.875rem;
		font-weight: 500;
		color: hsl(240 10% 3.9%);
	}

	.input {
		height: 2.5rem;
		padding: 0 0.75rem;
		border: 1px solid hsl(240 5.9% 90%);
		border-radius: 0.375rem;
		background: hsl(0 0% 100%);
		color: hsl(240 10% 3.9%);
		font-size: 0.875rem;
		outline: none;
		transition:
			border-color 0.15s,
			box-shadow 0.15s;
	}

	.input::placeholder {
		color: hsl(240 3.8% 70%);
	}

	.input:focus {
		border-color: hsl(240 5.9% 10%);
		box-shadow: 0 0 0 3px hsl(240 5.9% 10% / 0.1);
	}

	.btn-primary {
		height: 2.5rem;
		padding: 0 1rem;
		background: hsl(240 5.9% 10%);
		color: hsl(0 0% 98%);
		border: none;
		border-radius: 0.375rem;
		font-size: 0.875rem;
		font-weight: 500;
		cursor: pointer;
		transition: background 0.15s;
		margin-top: 0.25rem;
	}

	.btn-primary:hover:not(:disabled) {
		background: hsl(240 5.9% 20%);
	}

	.btn-primary:disabled {
		opacity: 0.5;
		cursor: not-allowed;
	}

	.footer-link {
		text-align: center;
		font-size: 0.875rem;
		color: hsl(240 3.8% 46.1%);
		margin-top: 1.25rem;
		margin-bottom: 0;
	}

	.footer-link a {
		color: hsl(240 5.9% 10%);
		font-weight: 500;
		text-decoration: underline;
		text-underline-offset: 3px;
	}
</style>

authClient.signIn.email() posts the credentials to POST /api/auth/sign-in/email. The catch-all route from Step 8 handles it. better-auth validates the credentials against the hashed password in the account table, creates a session record in the session table, and the sveltekitCookies() plugin writes the HttpOnly session cookie before the response leaves the server. From this point forward, every request the browser makes includes that cookie, and the hook from Step 9 resolves it into locals.user.


Step 12 — Build the Sign-Up Page

Sign-up follows the same shape as sign-in with an additional name field:

<!-- src/routes/register/+page.svelte -->
<script lang="ts">
	import { authClient } from '$lib/client/auth.js'
	import { goto, invalidateAll } from '$app/navigation'

	let name = $state('')
	let email = $state('')
	let password = $state('')
	let error = $state<string | null>(null)
	let submitting = $state(false)

	async function handleSubmit(event: Event) {
		event.preventDefault()
		error = null
		submitting = true

		const result = await authClient.signUp.email({ name, email, password })

		submitting = false

		if (result.error) {
			error = result.error.message ?? 'Registration failed. Please try again.'
			return
		}

		// Same as sign-in: invalidate first so the layout load re-runs
		// and the context reflects the newly created session.
		await invalidateAll()
		goto('/dashboard')
	}
</script>

<div class="page">
	<div class="card">
		<div class="card-header">
			<h1 class="card-title">Create account</h1>
			<p class="card-description">Fill in the details below to get started.</p>
		</div>

		{#if error}
			<div class="alert" role="alert">{error}</div>
		{/if}

		<form class="form" onsubmit={handleSubmit}>
			<div class="field">
				<label class="label" for="name">Name</label>
				<input
					id="name"
					class="input"
					type="text"
					bind:value={name}
					autocomplete="name"
					placeholder="Jane Smith"
					required
				/>
			</div>

			<div class="field">
				<label class="label" for="email">Email</label>
				<input
					id="email"
					class="input"
					type="email"
					bind:value={email}
					autocomplete="email"
					placeholder="you@example.com"
					required
				/>
			</div>

			<div class="field">
				<label class="label" for="password">Password</label>
				<input
					id="password"
					class="input"
					type="password"
					bind:value={password}
					autocomplete="new-password"
					placeholder="Min. 8 characters"
					minlength={8}
					required
				/>
			</div>

			<button class="btn-primary" type="submit" disabled={submitting}>
				{submitting ? 'Creating account…' : 'Create account'}
			</button>
		</form>

		<p class="footer-link">
			Already have an account? <a href="/login">Sign in</a>
		</p>
	</div>
</div>

<style>
	.page {
		min-height: 100vh;
		display: flex;
		align-items: center;
		justify-content: center;
		padding: 1rem;
		background-color: hsl(0 0% 98%);
	}

	.card {
		width: 100%;
		max-width: 400px;
		background: hsl(0 0% 100%);
		border: 1px solid hsl(240 5.9% 90%);
		border-radius: 0.5rem;
		padding: 2rem;
		box-shadow: 0 1px 3px hsl(0 0% 0% / 0.06);
	}

	.card-header {
		margin-bottom: 1.5rem;
	}

	.card-title {
		font-size: 1.25rem;
		font-weight: 600;
		color: hsl(240 10% 3.9%);
		margin: 0 0 0.25rem;
	}

	.card-description {
		font-size: 0.875rem;
		color: hsl(240 3.8% 46.1%);
		margin: 0;
	}

	.alert {
		background: hsl(0 86% 97%);
		border: 1px solid hsl(0 72% 88%);
		color: hsl(0 72% 40%);
		border-radius: 0.375rem;
		padding: 0.75rem 1rem;
		font-size: 0.875rem;
		margin-bottom: 1rem;
	}

	.form {
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}

	.field {
		display: flex;
		flex-direction: column;
		gap: 0.375rem;
	}

	.label {
		font-size: 0.875rem;
		font-weight: 500;
		color: hsl(240 10% 3.9%);
	}

	.input {
		height: 2.5rem;
		padding: 0 0.75rem;
		border: 1px solid hsl(240 5.9% 90%);
		border-radius: 0.375rem;
		background: hsl(0 0% 100%);
		color: hsl(240 10% 3.9%);
		font-size: 0.875rem;
		outline: none;
		transition:
			border-color 0.15s,
			box-shadow 0.15s;
	}

	.input::placeholder {
		color: hsl(240 3.8% 70%);
	}

	.input:focus {
		border-color: hsl(240 5.9% 10%);
		box-shadow: 0 0 0 3px hsl(240 5.9% 10% / 0.1);
	}

	.btn-primary {
		height: 2.5rem;
		padding: 0 1rem;
		background: hsl(240 5.9% 10%);
		color: hsl(0 0% 98%);
		border: none;
		border-radius: 0.375rem;
		font-size: 0.875rem;
		font-weight: 500;
		cursor: pointer;
		transition: background 0.15s;
		margin-top: 0.25rem;
	}

	.btn-primary:hover:not(:disabled) {
		background: hsl(240 5.9% 20%);
	}

	.btn-primary:disabled {
		opacity: 0.5;
		cursor: not-allowed;
	}

	.footer-link {
		text-align: center;
		font-size: 0.875rem;
		color: hsl(240 3.8% 46.1%);
		margin-top: 1.25rem;
		margin-bottom: 0;
	}

	.footer-link a {
		color: hsl(240 5.9% 10%);
		font-weight: 500;
		text-decoration: underline;
		text-underline-offset: 3px;
	}
</style>

better-auth handles password hashing internally, you never see or store plaintext passwords. The emailAndPassword() plugin uses bcrypt. A user record is written to the user table, a corresponding account record with the hashed password is written to the account table, and a session is created in the same response. By the time goto('/dashboard') runs, the user is already signed in.


Step 13 — Build the Logout Button

Logout clears the session server-side and then re-synchronises the client-side state. The invalidateAll() call is easy to overlook and causes a subtle bug when it is missing:

<!-- src/lib/components/LogoutButton.svelte -->
<script lang="ts">
	import { authClient } from '$lib/client/auth.js'
	import { goto, invalidateAll } from '$app/navigation'

	let loading = $state(false)

	async function handleLogout() {
		loading = true
		try {
			await authClient.signOut()
			// Discard SvelteKit's cached load results so page.data.user
			// reflects the now-cleared session before navigation.
			await invalidateAll()
			await goto('/')
		} finally {
			// Reset loading unconditionally. If this component stays
			// mounted (e.g. used on a page rather than in the root layout),
			// the button would stay disabled indefinitely without this.
			loading = false
		}
	}
</script>

<button class="btn" onclick={handleLogout} disabled={loading}>
	{loading ? 'Signing out…' : 'Sign out'}
</button>

<style>
	.btn {
		height: 2rem;
		padding: 0 0.75rem;
		background: transparent;
		color: hsl(240 10% 3.9%);
		border: 1px solid hsl(240 5.9% 90%);
		border-radius: 0.375rem;
		font-size: 0.8125rem;
		font-weight: 500;
		cursor: pointer;
		transition:
			background 0.15s,
			border-color 0.15s;
	}

	.btn:hover:not(:disabled) {
		background: hsl(240 4.8% 95.9%);
		border-color: hsl(240 5.9% 80%);
	}

	.btn:disabled {
		opacity: 0.5;
		cursor: not-allowed;
	}
</style>

authClient.signOut() calls POST /api/auth/sign-out, which deletes the session record from the database and clears the cookie. That is the server side done. invalidateAll() handles the client side, it discards SvelteKit’s load function cache and triggers a re-run. The re-run reads null from locals.user through the hook, returns it through the layout load function, and the reactive chain updates everything downstream.


Step 14 — Wire locals.user into Context

The final step makes locals.user available to your component tree. The layout server load function reads from locals and returns the user so it becomes part of page.data — accessible to the layout component and every page beneath it.

// src/routes/+layout.server.ts

import type { LayoutServerLoad } from './$types'

export const load: LayoutServerLoad = async ({ locals }) => {
	// locals.user was set by the hook in Step 9.
	// Returning it here makes it available as page.data.user
	// to the layout component and all pages.
	return {
		user: locals.user
	}
}

The layout component receives page.data.user and needs to make it available to the rest of the component tree. The simplest approach is reading page.data.user directly in any component — but this couples every component to page.data and makes it harder to swap the auth source later. Svelte’s context API is a better fit: set the user once in the root layout, and any component anywhere in the tree can read it without prop drilling.

The context module uses a Symbol key and typed wrapper functions to prevent key collisions and keep the type inference clean. This is the same pattern the companion context series uses throughout:

// src/lib/auth/context.svelte.ts

import { getContext, setContext } from 'svelte'
import type { User } from '$lib/server/auth.js'

// Symbol key — cannot be accidentally shadowed by a child component
// or a third-party library using the same string key.
const AUTH_KEY = Symbol('auth')

interface AuthContext {
	readonly user: User | null
	readonly isAuthenticated: boolean
}

// Accept a getter function rather than a static value.
// The context object is created once and set once — it never gets replaced.
// Because the getters call getUser() on every read, they always reflect
// the current page.data.user without any $effect or manual synchronisation.
// When invalidateAll() causes the layout load to re-run, page.data.user
// updates, and every component reading auth.user or auth.isAuthenticated
// picks up the new value automatically on the next render.
function createAuthContext(getUser: () => User | null): AuthContext {
	return {
		get user() {
			return getUser()
		},
		get isAuthenticated() {
			return getUser() !== null
		}
	}
}

export function setAuthContext(getUser: () => User | null): AuthContext {
	return setContext(AUTH_KEY, createAuthContext(getUser))
}

export function getAuthContext(): AuthContext {
	return getContext<AuthContext>(AUTH_KEY)
}

Set the context once in the root layout, then include a Nav component that reads from it to show the auth state:

<!-- src/routes/+layout.svelte -->
<script lang="ts">
	import { page } from '$app/state'
	import { setAuthContext } from '$lib/auth/context.svelte.js'
	import Nav from '$lib/components/Nav.svelte'

	// Pass a getter so the context always reads the current page.data.user.
	// The context object is created once and never replaced — components
	// holding a reference to it via getAuthContext() see updates immediately
	// after invalidateAll() causes the layout load to re-run.
	setAuthContext(() => page.data.user)

	let { children } = $props()
</script>

<Nav />

<main class="main">
	{@render children?.()}
</main>

<style>
	:global(*, *::before, *::after) {
		box-sizing: border-box;
	}

	:global(body) {
		margin: 0;
		background: hsl(0 0% 98%);
		color: hsl(240 10% 3.9%);
		font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
		-webkit-font-smoothing: antialiased;
	}

	:global(a) {
		color: inherit;
	}

	.main {
		max-width: 1200px;
		margin: 0 auto;
		padding: 2rem 1.5rem;
	}
</style>

Nav calls getAuthContext() to read the current user and renders sign-in or sign-out controls accordingly. The logout button calls authClient.signOut() followed by invalidateAll() — the same pattern from Step 13 — so the context updates immediately without a full page reload:

<!-- src/lib/components/Nav.svelte -->
<script lang="ts">
	import { page } from '$app/state'
	import { goto, invalidateAll } from '$app/navigation'
	import { authClient } from '$lib/client/auth.js'
	import { getAuthContext } from '$lib/auth/context.svelte.js'

	const auth = getAuthContext()

	let loading = $state(false)

	async function handleLogout() {
		loading = true
		try {
			await authClient.signOut()
			await invalidateAll()
			await goto('/')
		} finally {
			// Nav lives in the root layout and never unmounts, so loading
			// state persists across navigations. Reset it unconditionally
			// once the full logout sequence completes or fails.
			loading = false
		}
	}
</script>

<header class="header">
	<nav class="nav">
		<a class="brand" href="/">MyApp</a>

		<div class="links">
			{#if auth.isAuthenticated}
				<a class="nav-link" href="/dashboard">Dashboard</a>
				<span class="user-name">{auth.user?.name}</span>
				<button class="btn-outline" onclick={handleLogout} disabled={loading}>
					{loading ? 'Signing out…' : 'Sign out'}
				</button>
			{:else}
				<a class="nav-link" href="/login">Sign in</a>
				<a class="btn-primary" href="/register">Create account</a>
			{/if}
		</div>
	</nav>
</header>

<style>
	.header {
		position: sticky;
		top: 0;
		z-index: 50;
		background: hsl(0 0% 100% / 0.95);
		backdrop-filter: blur(8px);
		border-bottom: 1px solid hsl(240 5.9% 90%);
	}

	.nav {
		max-width: 1200px;
		margin: 0 auto;
		padding: 0 1.5rem;
		height: 3.5rem;
		display: flex;
		align-items: center;
		justify-content: space-between;
	}

	.brand {
		font-size: 1rem;
		font-weight: 600;
		color: hsl(240 10% 3.9%);
		text-decoration: none;
		letter-spacing: -0.01em;
	}

	.links {
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}

	.nav-link {
		padding: 0 0.5rem;
		height: 2rem;
		display: flex;
		align-items: center;
		font-size: 0.875rem;
		color: hsl(240 3.8% 46.1%);
		text-decoration: none;
		border-radius: 0.375rem;
		transition:
			color 0.15s,
			background 0.15s;
	}

	.nav-link:hover {
		color: hsl(240 10% 3.9%);
		background: hsl(240 4.8% 95.9%);
	}

	.user-name {
		padding: 0 0.5rem;
		font-size: 0.875rem;
		color: hsl(240 10% 3.9%);
		font-weight: 500;
	}

	.btn-primary {
		height: 2rem;
		padding: 0 0.875rem;
		background: hsl(240 5.9% 10%);
		color: hsl(0 0% 98%);
		border-radius: 0.375rem;
		font-size: 0.8125rem;
		font-weight: 500;
		text-decoration: none;
		display: flex;
		align-items: center;
		transition: background 0.15s;
	}

	.btn-primary:hover {
		background: hsl(240 5.9% 20%);
	}

	.btn-outline {
		height: 2rem;
		padding: 0 0.75rem;
		background: transparent;
		color: hsl(240 10% 3.9%);
		border: 1px solid hsl(240 5.9% 90%);
		border-radius: 0.375rem;
		font-size: 0.8125rem;
		font-weight: 500;
		cursor: pointer;
		transition:
			background 0.15s,
			border-color 0.15s;
	}

	.btn-outline:hover:not(:disabled) {
		background: hsl(240 4.8% 95.9%);
		border-color: hsl(240 5.9% 80%);
	}

	.btn-outline:disabled {
		opacity: 0.5;
		cursor: not-allowed;
	}
</style>

With this in place you can verify the full auth flow in one place: sign in on /login, watch the nav switch to the authenticated state showing the user’s name, then sign out and watch it revert — all without a page reload.

When the user logs out, invalidateAll() from Step 13 re-runs the layout server load, page.data.user becomes null, and the getter inside the context reads the new value on the next render — every component using auth.user or auth.isAuthenticated updates without any manual synchronisation.

This minimal version covers the most common needs. The AuthContext interface here exposes user and isAuthenticated. The companion article Authentication Architecture with Context extends this with role-based permission checking (hasPermission, hasAnyPermission, hasAllPermissions), a discriminated union AuthState type that TypeScript can narrow, $derived.by for reactive state that survives invalidate() without drift, and the full rationale for each design choice. If your application needs more than “is this user logged in”, that article is the right next step.


Step 15 — Build the Dashboard Page

The dashboard is a protected page that reads the authenticated user from locals via the server load and displays it. This is also the redirect target after sign-in and registration.

// src/routes/dashboard/+page.server.ts

import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals }) => {
	if (!locals.user) {
		redirect(303, '/login')
	}

	return { user: locals.user }
}
<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
	import type { PageData } from './$types'

	let { data }: { data: PageData } = $props()
</script>

<div class="page">
	<div class="header">
		<div>
			<h1 class="title">Dashboard</h1>
			<p class="subtitle">Welcome back, {data.user.name}.</p>
		</div>
	</div>

	<div class="grid">
		<div class="card">
			<div class="card-header">
				<span class="card-label">Account</span>
			</div>
			<div class="card-body">
				<div class="field">
					<span class="field-label">Name</span>
					<span class="field-value">{data.user.name}</span>
				</div>
				<div class="field">
					<span class="field-label">Email</span>
					<span class="field-value">{data.user.email}</span>
				</div>
				<div class="field">
					<span class="field-label">User ID</span>
					<span class="field-value mono">{data.user.id}</span>
				</div>
				<div class="field">
					<span class="field-label">Email verified</span>
					<span class="badge {data.user.emailVerified ? 'badge-success' : 'badge-warning'}">
						{data.user.emailVerified ? 'Verified' : 'Not verified'}
					</span>
				</div>
			</div>
		</div>

		<div class="card">
			<div class="card-header">
				<span class="card-label">Session</span>
			</div>
			<div class="card-body">
				<div class="field">
					<span class="field-label">Status</span>
					<span class="badge badge-success">Active</span>
				</div>
				<div class="field">
					<span class="field-label">Member since</span>
					<span class="field-value">
						{new Date(data.user.createdAt).toLocaleDateString('en-GB', {
							day: 'numeric',
							month: 'long',
							year: 'numeric'
						})}
					</span>
				</div>
			</div>
		</div>
	</div>
</div>

<style>
	.page {
		max-width: 800px;
	}

	.header {
		margin-bottom: 2rem;
	}

	.title {
		font-size: 1.5rem;
		font-weight: 600;
		color: hsl(240 10% 3.9%);
		margin: 0 0 0.25rem;
	}

	.subtitle {
		font-size: 0.875rem;
		color: hsl(240 3.8% 46.1%);
		margin: 0;
	}

	.grid {
		display: grid;
		grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
		gap: 1rem;
	}

	.card {
		background: hsl(0 0% 100%);
		border: 1px solid hsl(240 5.9% 90%);
		border-radius: 0.5rem;
		overflow: hidden;
	}

	.card-header {
		padding: 1rem 1.25rem 0.75rem;
		border-bottom: 1px solid hsl(240 5.9% 90%);
	}

	.card-label {
		font-size: 0.8125rem;
		font-weight: 600;
		color: hsl(240 10% 3.9%);
		text-transform: uppercase;
		letter-spacing: 0.04em;
	}

	.card-body {
		padding: 1rem 1.25rem;
		display: flex;
		flex-direction: column;
		gap: 0.875rem;
	}

	.field {
		display: flex;
		justify-content: space-between;
		align-items: center;
		gap: 1rem;
	}

	.field-label {
		font-size: 0.8125rem;
		color: hsl(240 3.8% 46.1%);
		flex-shrink: 0;
	}

	.field-value {
		font-size: 0.8125rem;
		color: hsl(240 10% 3.9%);
		font-weight: 500;
		text-align: right;
		word-break: break-all;
	}

	.mono {
		font-family: 'SF Mono', 'Fira Code', monospace;
		font-size: 0.75rem;
		color: hsl(240 3.8% 46.1%);
	}

	.badge {
		display: inline-flex;
		align-items: center;
		height: 1.375rem;
		padding: 0 0.5rem;
		border-radius: 9999px;
		font-size: 0.75rem;
		font-weight: 500;
	}

	.badge-success {
		background: hsl(142 76% 95%);
		color: hsl(142 72% 29%);
	}

	.badge-warning {
		background: hsl(48 96% 93%);
		color: hsl(32 95% 35%);
	}
</style>

The server load redirects unauthenticated visitors to /login before any HTML is rendered. The page itself only receives data that came through locals.user — server-validated on every request by the hook.


Testing the Full Flow

With all 15 steps complete, verify everything works in this order:

  1. Start the dev server: pnpm dev
  2. Open http://localhost:5173/register and create an account
  3. You should be redirected to /dashboard and the nav should show your name and a sign-out button
  4. Click sign out — the nav should revert to the sign-in and register links immediately
  5. Open http://localhost:5173/login and sign in with the credentials you just registered
  6. You should be back in the authenticated state

The most common mistake at this point is going to /login before ever registering, which produces User not found in the server logs. There are no seed users — the database starts empty and every account must be created through /register first.


Common Mistakes and Anti-Patterns

Running the CLI Before auth.ts Exists

The CLI reads auth.ts to determine which plugins are active and which tables they need. Running it before Step 4 is complete produces No configuration file found.

# Avoid — auth.ts does not exist yet. CLI cannot determine the schema.
npx @better-auth/cli generate --output schema.sql

# Preferred — complete Step 4 first, then generate.
npx @better-auth/cli generate --config src/lib/server/auth.ts --output schema.sql

Importing the Server Auth Instance in Browser Code

// Avoid — auth.ts holds a database connection.
//    That connection does not exist in the browser.
//    SvelteKit will refuse to build this.
import { auth } from '$lib/server/auth.js'
// Preferred — use the browser client from Step 10.
import { authClient } from '$lib/client/auth.js'

Calling getSession Again Inside Load Functions

// Avoid — the hook already validated this session for this exact request.
//    This runs a redundant database query on every page load.
export const load: PageServerLoad = async ({ request }) => {
	const session = await auth.api.getSession({ headers: request.headers })
	if (!session) redirect(303, '/login')
	return { user: session.user }
}

// Preferred — the hook already did this work. Read from locals.
export const load: PageServerLoad = async ({ locals }) => {
	if (!locals.user) redirect(303, '/login')
	return { user: locals.user }
}

Forgetting invalidateAll After Sign-In or Logout

The layout server load runs once when the page loads. After any auth state change, the cached result still holds the previous user value until you tell SvelteKit to discard it. This applies to both sign-in and sign-out.

// Avoid — the cookie is set, but page.data.user is still null from before
//    sign-in. The dashboard sees the unauthenticated state.
async function handleSignIn() {
	await authClient.signIn.email({ email, password })
	goto('/dashboard')
}

// Preferred — invalidate first so the layout load re-runs and picks up locals.user.
async function handleSignIn() {
	await authClient.signIn.email({ email, password })
	await invalidateAll()
	goto('/dashboard')
}
// Avoid — the cookie is cleared, but page.data.user is still the cached
//    authenticated value. The nav stays stuck showing the user as signed in.
async function handleLogout() {
	await authClient.signOut()
	goto('/')
}

// Preferred — same fix. Invalidate before navigating.
async function handleLogout() {
	await authClient.signOut()
	await invalidateAll()
	goto('/')
}

Using localStorage as the Session Store

The session cookie is HttpOnly and JavaScript cannot read it, and that is intentional. User data should only reach browser code through a server load function, which means it was validated by the hook before leaving the server.

// Avoid — any script on the page can read localStorage.
//    Svelte 5 note: $effect replaces onMount for reactive side-effects,
//    but localStorage is the wrong store regardless of which lifecycle you use.
let user = $state(null)
$effect(() => {
	const stored = localStorage.getItem('user')
	if (stored) user = JSON.parse(stored)
})

// Preferred — read from page.data. It came from a server load function
//    validated by the hook before it left the server.
import { page } from '$app/state'
let user = $derived(page.data.user)

Performance and Scaling Considerations

auth.api.getSession runs on every request that passes through the hook. For SQLite on a single server this is an indexed lookup against an in-process database, typically well under a millisecond. Two scaling situations are worth being aware of before you need them.

Moving beyond a single server. SQLite is a file on disk. If you run multiple server processes, horizontal scaling, serverless functions, each process has its own copy and sessions written on one are invisible to others. Switch to a shared database via its adapter. The change is the database field in auth.ts and the connection setup in db.ts. The hook, client, and UI are unchanged.

Skipping the session lookup on high-traffic public routes. This is a performance optimisation, not a security measure. Route security still depends entirely on the if (!locals.user) redirect(...) checks inside each protected load function — the hook never needs to enforce that. What the hook can skip, when warranted, is the database round-trip for routes that never use session data at all.

The straightforward approach is a hardcoded path Set. It is easy to read but requires manual updates as public routes are added:

// src/hooks.server.ts — explicit path Set (simple, but can drift)

import { auth } from '$lib/server/auth.js'
import type { Handle } from '@sveltejs/kit'

const PUBLIC_PATHS = new Set(['/', '/about', '/pricing', '/blog'])

export const handle: Handle = async ({ event, resolve }) => {
	if (PUBLIC_PATHS.has(event.url.pathname)) {
		event.locals.user = null
		event.locals.session = null
		return resolve(event)
	}

	const session = await auth.api.getSession({
		headers: event.request.headers
	})

	event.locals.user = session?.user ?? null
	event.locals.session = session?.session ?? null

	return resolve(event)
}

A more scalable alternative is to organise public routes into a SvelteKit route group and check event.route.id instead. Any route inside src/routes/(public)/ is skipped automatically — no list to maintain:

// src/hooks.server.ts — route group approach (self-maintaining)

import { auth } from '$lib/server/auth.js'
import type { Handle } from '@sveltejs/kit'

export const handle: Handle = async ({ event, resolve }) => {
	// event.route.id can be null for unmatched routes and some internal
	// requests, so the optional chaining here is required, not optional.
	if (event.route.id?.startsWith('/(public)')) {
		event.locals.user = null
		event.locals.session = null
		return resolve(event)
	}

	const session = await auth.api.getSession({
		headers: event.request.headers
	})

	event.locals.user = session?.user ?? null
	event.locals.session = session?.session ?? null

	return resolve(event)
}

With this in place, adding a new public route is as simple as placing it under src/routes/(public)/. The hook catches it without any code change. The route group name has no effect on the URL — /about is still /about regardless of which group its files sit in.

Which approach to use depends on your project structure. The path Set is fine for small applications where the public surface is stable. The route group is the better default for anything that will grow, since it keeps the file system and the hook in permanent agreement.

Either way: add this only after you have measured that the session lookup is actually a bottleneck. For most applications it is not.


When NOT to Use This Pattern

better-auth with email/password is a solid foundation, but not the right fit for every situation.

If your users authenticate through an external identity provider, Google Workspace, GitHub, an enterprise SAML system, you may not need local passwords at all. better-auth supports OAuth providers as additional plugins added to the plugins array in Step 4. Users authenticate against the external provider; your database stores only the resulting session. The hook, client, and context wiring from this article remain unchanged.

If you are building a purely client-side SPA with no SvelteKit server (adapter-static, no server routes), session-cookie-based auth does not apply. You would validate tokens on a separate API server and store the access token in memory on the client, not in localStorage.

If you need complex authorization — row-level security, attribute-based access control, multi-tenant permission trees — the context interface from Step 14 is the right extension point, but this article only wires the user in. Read Authentication Architecture with Context for a full treatment of role-based permissions built on top of the same foundation.


Conclusion

The fifteen steps in this article follow a strict dependency order: each step creates something the next step depends on. Packages first, then environment variables, then the database connection, then the auth configuration (which the CLI reads to generate the schema), then the schema itself, then the routes and hooks that use it, then the browser client, then the UI, then the context bridge that connects everything to the component tree.

When the sequence is right, each piece slots into the next without friction. The database layer is a single substitution point. The hook is three lines. The browser client is a factory call. The UI is ordinary Svelte 5 form handling. None of these require novelty, they all use the tools SvelteKit already provides.


Key Takeaways

Install better-auth and better-sqlite3 as dependencies, and @types/better-sqlite3 as a dev dependency. Do not install @better-auth/cli as a dependency, it pulls Prisma in via pnpm peer resolution.

Run pnpm approve-builds better-sqlite3 immediately after install. better-sqlite3 compiles a native binary and will not work until the build is approved.

Create src/lib/server/auth.ts before running the CLI. The CLI reads the config to determine the schema. Run it as npx @better-auth/cli generate --config src/lib/server/auth.ts --output schema.sql.

Declare locals.user and locals.session in src/app.d.ts before writing hooks.server.ts. TypeScript needs the ambient declaration before it will accept the assignments.

sveltekitCookies(getRequestEvent) is not optional, and must be the last plugin in the plugins array. Without it, session cookies are silently dropped and sign-in appears to fail with no error message. Without getRequestEvent, you get a TypeScript error and the same silent cookie failure at runtime.

The catch-all route in Step 8 and the getSession call in Step 9 can be replaced by svelteKitHandler from better-auth/svelte-kit, which combines both concerns into a single hook. This article separates them for teaching clarity; better-auth’s own docs recommend the combined approach once you are comfortable with the wiring.

The hook runs once per request and populates locals. Every load function in that request reads from locals, never call getSession a second time inside a load function.

Call invalidateAll() after logout before navigating. Without it, SvelteKit serves the cached load results and the UI stays stuck showing the user as authenticated.

The context in Step 14 uses a Symbol key and typed wrapper functions (setAuthContext / getAuthContext). This prevents key collisions, keeps consumers fully typed, and matches the pattern used throughout the context series. String keys are a footgun: any component that calls setContext('auth', ...) with a different shape silently replaces the value.

Skipping the session lookup for public routes in the hook is a performance optimisation, not a security feature. Route security is enforced by if (!locals.user) redirect(...) inside each protected load function, regardless of what the hook does or does not skip.


Further Reading

Track complete
You've finished SvelteKit Reference.