Build a Full-Stack SvelteKit Starter Template with PostgreSQL, Prisma, Skeleton UI, and Better Auth


Tags: sveltekit postgresql prisma full-stack deployment guide better-auth skeleton-ui form-actions tutorial

What You’ll Build

A production-ready SvelteKit starter template featuring:

  • SvelteKit with file-based routing and form actions
  • PostgreSQL database hosted on Railway
  • Prisma ORM for type-safe database access
  • Better Auth with email/password authentication
  • User management: Registration, login, logout, and profile editing
  • Item CRUD: Full create, read, update, delete operations
  • Skeleton UI: Polished, accessible components
  • Dashboard: Stats and recent items overview
  • Toast notifications for user feedback
  • Form actions for server-side mutations
  • Pagination for managing items

Prerequisites

Before starting, make sure you have:

  • Node.js 18+ installed (download here)
  • Git installed and configured
  • Git Bash (all codeblocks in this guide are designed to be pasted into a Git Bash terminal)
  • GitHub account for code hosting
  • Railway account (sign up with our referral link for $20 free credits)

Create Your SvelteKit Project

Initialize SvelteKit Application

Terminal window
npx sv create sv-pg-prisma

When prompted, choose:

  • Template: SvelteKit minimal
  • TypeScript: Yes
  • Additional options: ESLint, Prettier, Tailwind CSS, sveltekit-adapter
  • Tailwind CSS plugins: None (leave both typography and forms unchecked)
  • sveltekit-adapter: node
  • Which package manager: npm
Terminal window
cd sv-pg-prisma

Install Dependencies

Install all required packages for authentication and database access:

Terminal window
npm install better-auth
Terminal window
npm install @prisma/client@^5.22.0
Terminal window
npm install -D prisma@^5.22.0 @types/node

Note: Prisma Client will be generated automatically when you run migrations in the next steps.

Add Start Script

Railway needs a start script to run your application. Open package.json and add "start": "node build" to the scripts section.

"scripts": {
// ... other existing scripts ...
"start": "node build"
}

Configure Skeleton UI

Install Skeleton UI

Install the Skeleton core and Svelte component packages:

Terminal window
npm install -D @skeletonlabs/skeleton @skeletonlabs/skeleton-svelte

Configure Skeleton Styles

Create src/app.css to add Skeleton imports:

Terminal window
cat > src/app.css << 'EOF'
@import 'tailwindcss';
@import '@skeletonlabs/skeleton';
@import '@skeletonlabs/skeleton/themes/cerberus';
EOF

Set the Active Theme

Create src/app.html to set the active theme:

Terminal window
cat > src/app.html << 'EOF'
<!doctype html>
<html lang="en" data-theme="cerberus">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
EOF

This creates the file with the data-theme="cerberus" attribute on the <html> tag, which enables Skeleton UI’s theme system.

This sets up:

  • Skeleton UI component library
  • Theme system (using the “cerberus” theme)
  • Integration with Tailwind CSS

Set Up Railway and PostgreSQL

Install Railway CLI

Terminal window
npm install -g @railway/cli

Restart your terminal after installation.

Note: Railway CLI requires Node.js. If you don’t have Node.js installed, you can download it from nodejs.org.

Login to Railway

Terminal window
railway login

Create Railway Project

Terminal window
railway init

When prompted:

  1. Select a workspace: Choose your workspace
  2. Project Name: Empty Project for a randomly generated name or sv-pg-prisma

Add PostgreSQL Database

Terminal window
railway add -d postgres

This creates a PostgreSQL database and links it to your Railway project.

Terminal window
railway link

When prompted, make the following selections:

  1. Select a workspace: Choose your workspace
  2. Select a project: Choose the project you just created
  3. Select an environment: Choose production
  4. Select a service: Choose Postgres

Note: If you don’t see “Select a service” appear, you may need to run railway link again to ensure the Postgres service is properly linked.

Configure Prisma

Create Prisma Schema

Create the Prisma directory and schema file:

Terminal window
mkdir -p prisma
Terminal window
cat > prisma/schema.prisma << 'EOF'
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
items Item[]
}
model Account {
id String @id @default(cuid())
accountId String
providerId String
userId String
password String?
accessToken String? @db.Text
refreshToken String? @db.Text
idToken String? @db.Text
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([providerId, accountId])
@@index([userId])
}
model Session {
id String @id @default(cuid())
token String @unique
userId String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Item {
id String @id @default(cuid())
name String
description String?
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
EOF

This schema includes:

  • User: Authentication and profile data
  • Account: Better Auth provider accounts
  • Session: Better Auth session management
  • Item: Generic CRUD example model

Configure Local Database Connection

Railway’s internal database URL (postgres.railway.internal) isn’t accessible from your local machine. You need to use the public TCP proxy URL instead.

Get your database’s public connection string:

Terminal window
railway variables --service Postgres --json | grep DATABASE_PUBLIC_URL

Once you have the DATABASE_PUBLIC_URL, update your .env file:

Terminal window
cat > .env << 'EOF'
DATABASE_URL="<paste your DATABASE_PUBLIC_URL here>?sslmode=require"
EOF

Replace <paste your DATABASE_PUBLIC_URL here> with the actual URL. It should look like:

DATABASE_URL="postgresql://postgres:password@region.proxy.rlwy.net:PORT/railway?sslmode=require"

Note: The ?sslmode=require parameter is added in the command above and is required for secure connections.

Create Prisma Client Singleton

SvelteKit uses hot reloading in development, which can create multiple instances of Prisma Client. This singleton pattern ensures you reuse the same instance across hot reloads, preventing database connection exhaustion.

Create the server-only database client:

Terminal window
mkdir -p src/lib/server && cat > src/lib/server/db.ts << 'EOF'
import { PrismaClient } from '@prisma/client';
import { dev } from '$app/environment';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma || new PrismaClient({
log: dev ? ['query', 'error', 'warn'] : ['error'],
});
if (dev) globalForPrisma.prisma = prisma;
EOF

This file exports a single prisma instance that you’ll import in your server-side code to query the database. Prisma Client uses lazy connections, meaning it will automatically connect to the database on the first query. In development, it logs queries for debugging. In production, it only logs errors.

Generate Migration Files

Now generate the migration files using your local environment variable:

Terminal window
npx prisma migrate dev --name init

This command:

  • Uses the DATABASE_URL you just set
  • Creates a prisma/migrations directory with SQL migration files
  • Applies the migration to your Railway database
  • Generates Prisma Client

Note: If you get a “Can’t reach database server” error (P1001), your Railway PostgreSQL service may have gone to sleep due to inactivity. Wait 10-20 seconds for it to resume, then run the command again.

Configure Authentication (Better Auth)

Create Auth Configuration

Create the main auth configuration file:

Terminal window
cat > src/lib/server/auth.ts << 'EOF'
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import { prisma } from './db';
import { env } from '$env/dynamic/private';
if (!env.BETTER_AUTH_SECRET) {
throw new Error('BETTER_AUTH_SECRET environment variable is required');
}
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
emailAndPassword: {
enabled: true,
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
},
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL || 'http://localhost:3000',
plugins: [sveltekitCookies(getRequestEvent)],
});
EOF

This configuration:

  • Validates that BETTER_AUTH_SECRET is set (throws error at startup if missing)
  • Connects Better Auth to your Prisma database
  • Enables email/password authentication
  • Sets session expiration (7 days) and refresh intervals (1 day)
  • Uses environment variables for security
  • Automatically handles cookies in server actions via the sveltekitCookies plugin

Create Type Definitions

Update src/app.d.ts to add type safety for authenticated user data:

Terminal window
cat > src/app.d.ts << 'EOF'
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
user: {
id: string;
email: string;
name?: string;
image?: string;
} | null;
session: {
id: string;
userId: string;
expiresAt: Date;
} | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
EOF

Create Server Hooks

Create src/hooks.server.ts to handle authentication and validate sessions on every request:

Terminal window
cat > src/hooks.server.ts << 'EOF'
import { auth } from '$lib/server/auth';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { building } from '$app/environment';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
// Fetch current session from Better Auth
const session = await auth.api.getSession({
headers: event.request.headers,
});
// Make session and user available on server
if (session) {
event.locals.user = session.user;
event.locals.session = session.session;
} else {
event.locals.user = null;
event.locals.session = null;
}
// Handle Better Auth routes and all other requests
return svelteKitHandler({ event, resolve, auth, building });
};
EOF

This hook:

  • Runs on every request
  • Fetches and validates the session from Better Auth
  • Attaches user and session to event.locals for use throughout your app
  • Automatically handles all /api/auth/* routes via svelteKitHandler
  • Processes all other requests normally

Better Auth API routes are automatically available at /api/auth/*:

  • /api/auth/sign-in/email - Sign in with email/password
  • /api/auth/sign-up/email - Register new user
  • /api/auth/sign-out - Sign out user
  • /api/auth/session - Get current session

Implement Form Actions

Create Registration Action

Create the registration page with form action:

Terminal window
mkdir -p src/routes/register && cat > src/routes/register/+page.server.ts << 'EOF'
import { fail, redirect } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
redirect(307, '/dashboard');
}
return {};
};
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const name = data.get('name');
const email = data.get('email');
const password = data.get('password');
if (!name || typeof name !== 'string' || name.length < 2) {
return fail(400, {
error: 'Name must be at least 2 characters',
name: name || '',
email: email || '',
});
}
if (!email || typeof email !== 'string') {
return fail(400, {
error: 'Valid email is required',
name,
email: email || '',
});
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return fail(400, {
error: 'Valid email is required',
name,
email,
});
}
if (!password || typeof password !== 'string' || password.length < 8) {
return fail(400, {
error: 'Password must be at least 8 characters',
name,
email,
});
}
try {
await auth.api.signUpEmail({
body: { name, email, password },
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Registration failed';
return fail(400, {
error: message,
name,
email,
});
}
redirect(303, '/dashboard');
},
};
EOF

Create Login Action

Create the login page with form action:

Terminal window
mkdir -p src/routes/login && cat > src/routes/login/+page.server.ts << 'EOF'
import { fail, redirect } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
redirect(307, '/dashboard');
}
return {};
};
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
const password = data.get('password');
if (!email || typeof email !== 'string') {
return fail(400, {
error: 'Valid email is required',
email: email || '',
});
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return fail(400, {
error: 'Valid email is required',
email,
});
}
if (!password || typeof password !== 'string') {
return fail(400, {
error: 'Password is required',
email,
});
}
try {
await auth.api.signInEmail({
body: { email, password },
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Invalid email or password';
return fail(401, {
error: message,
email,
});
}
redirect(303, '/dashboard');
},
};
EOF

Create Item CRUD Actions

Create form actions for managing items in the dashboard:

Terminal window
mkdir -p src/routes/dashboard && cat > src/routes/dashboard/+page.server.ts << 'EOF'
import { fail, redirect } from '@sveltejs/kit';
import { prisma } from '$lib/server/db';
import type { Actions, PageServerLoad } from './$types';
const ITEMS_PER_PAGE = 10;
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.user) {
redirect(307, '/login');
}
const page = parseInt(url.searchParams.get('page') || '1');
const skip = (page - 1) * ITEMS_PER_PAGE;
const [items, totalCount] = await Promise.all([
prisma.item.findMany({
where: { userId: locals.user.id },
orderBy: { createdAt: 'desc' },
skip,
take: ITEMS_PER_PAGE,
}),
prisma.item.count({ where: { userId: locals.user.id } }),
]);
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
return {
items,
totalCount,
currentPage: page,
totalPages,
user: locals.user,
};
};
export const actions: Actions = {
create: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Unauthorized' });
}
const data = await request.formData();
const name = data.get('name');
const description = data.get('description');
if (!name || typeof name !== 'string' || name.length < 3) {
return fail(400, {
error: 'Name must be at least 3 characters',
name: name || '',
description: description || '',
});
}
try {
await prisma.item.create({
data: {
name,
description: typeof description === 'string' ? description : null,
userId: locals.user.id,
},
});
return { success: true };
} catch (error: unknown) {
return fail(500, { error: 'Failed to create item' });
}
},
update: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Unauthorized' });
}
const data = await request.formData();
const id = data.get('id');
const name = data.get('name');
const description = data.get('description');
if (!id || typeof id !== 'string') {
return fail(400, { error: 'Invalid item ID' });
}
if (!name || typeof name !== 'string' || name.length < 3) {
return fail(400, { error: 'Name must be at least 3 characters' });
}
try {
const item = await prisma.item.findUnique({
where: { id },
select: { userId: true },
});
if (!item || item.userId !== locals.user.id) {
return fail(403, { error: 'Forbidden' });
}
await prisma.item.update({
where: { id },
data: {
name,
description: typeof description === 'string' ? description : null,
},
});
return { success: true, updated: id };
} catch (error: unknown) {
return fail(500, { error: 'Failed to update item' });
}
},
delete: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Unauthorized' });
}
const data = await request.formData();
const id = data.get('id');
if (!id || typeof id !== 'string') {
return fail(400, { error: 'Invalid item ID' });
}
try {
const item = await prisma.item.findUnique({
where: { id },
select: { userId: true },
});
if (!item || item.userId !== locals.user.id) {
return fail(403, { error: 'Forbidden' });
}
await prisma.item.delete({ where: { id } });
return { success: true, deleted: id };
} catch (error: unknown) {
return fail(500, { error: 'Failed to delete item' });
}
},
};
EOF

Create Profile Update Action

Create form action for updating user profile:

Terminal window
mkdir -p src/routes/profile && cat > src/routes/profile/+page.server.ts << 'EOF'
import { fail, redirect } from '@sveltejs/kit';
import { prisma } from '$lib/server/db';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(307, '/login');
}
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: {
name: true,
email: true,
image: true,
},
});
return { user };
};
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Unauthorized' });
}
const data = await request.formData();
const name = data.get('name');
const image = data.get('image');
if (!name || typeof name !== 'string' || name.length < 2) {
return fail(400, {
error: 'Name must be at least 2 characters',
name: name || '',
});
}
try {
await prisma.user.update({
where: { id: locals.user.id },
data: {
name,
image: typeof image === 'string' && image.length > 0 ? image : null,
},
});
return { success: true };
} catch (error: unknown) {
return fail(500, { error: 'Failed to update profile' });
}
},
};
EOF

Build the Application

Create Toaster Store

Create a shared toaster instance for toast notifications:

Terminal window
mkdir -p src/lib/stores && cat > src/lib/stores/toaster.ts << 'EOF'
import { createToaster } from '@skeletonlabs/skeleton-svelte';
export const toaster = createToaster({});
EOF

Create Root Layout

Create src/routes/+layout.svelte to set up the global layout with navigation:

Terminal window
cat > src/routes/+layout.svelte << 'EOF'
<script lang="ts">
import '../app.css';
import { page } from '$app/stores';
import NavBar from '$lib/components/NavBar.svelte';
import { Toast } from '@skeletonlabs/skeleton-svelte';
import { toaster } from '$lib/stores/toaster';
</script>
<div class="min-h-screen bg-surface-50-900">
<NavBar user={$page.data.user} />
<main>
<slot />
</main>
</div>
<Toast.Group {toaster}>
{#snippet children(toast)}
<Toast {toast}>
<Toast.Message>
<Toast.Title>{toast.title}</Toast.Title>
<Toast.Description>{toast.description}</Toast.Description>
</Toast.Message>
<Toast.CloseTrigger />
</Toast>
{/snippet}
</Toast.Group>
EOF

Create the corresponding server load function:

Terminal window
cat > src/routes/+layout.server.ts << 'EOF'
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user,
};
};
EOF

Create Landing Page

Create the server load function for the landing page:

Terminal window
cat > src/routes/+page.server.ts << 'EOF'
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
redirect(307, '/dashboard');
}
return {};
};
EOF

Create src/routes/+page.svelte for unauthenticated users:

Terminal window
cat > src/routes/+page.svelte << 'EOF'
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<div class="container mx-auto px-4 py-16">
<div class="max-w-4xl mx-auto text-center space-y-8">
<h1 class="h1 text-6xl font-bold">SvelteKit Starter</h1>
<p class="text-xl text-surface-600-300">
A production-ready template with authentication, database, and deployment ready to go.
</p>
<div class="flex gap-4 justify-center">
<a href="/login" class="btn preset-filled-primary-500">
Login
</a>
<a href="/register" class="btn preset-outlined-primary-500">
Register
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12">
<div class="card preset-filled-surface-200-800 p-6">
<h3 class="h3 mb-2">SvelteKit</h3>
<p class="text-sm">Fast, modern framework with form actions and file-based routing</p>
</div>
<div class="card preset-filled-surface-200-800 p-6">
<h3 class="h3 mb-2">Better Auth</h3>
<p class="text-sm">Secure, session-based authentication with email/password</p>
</div>
<div class="card preset-filled-surface-200-800 p-6">
<h3 class="h3 mb-2">Skeleton UI</h3>
<p class="text-sm">Beautiful, accessible components built on Tailwind CSS</p>
</div>
</div>
</div>
</div>
EOF

Create Registration Page

Create src/routes/register/+page.svelte:

Terminal window
cat > src/routes/register/+page.svelte << 'EOF'
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
export let form: ActionData;
</script>
<div class="container mx-auto px-4 py-16">
<div class="max-w-md mx-auto">
<div class="card preset-filled-surface-100-900 p-8">
<h1 class="h1 text-3xl font-bold mb-6 text-center">Create Account</h1>
{#if form?.error}
<div class="alert preset-filled-error-500 mb-4">
<p>{form.error}</p>
</div>
{/if}
<form method="POST" use:enhance class="space-y-4">
<label class="label">
<span class="label-text">Name</span>
<input
class="input"
type="text"
name="name"
value={form?.name || ''}
required
placeholder="Enter your name"
/>
</label>
<label class="label">
<span class="label-text">Email</span>
<input
class="input"
type="email"
name="email"
value={form?.email || ''}
required
placeholder="Enter your email"
/>
</label>
<label class="label">
<span class="label-text">Password</span>
<input
class="input"
type="password"
name="password"
required
placeholder="At least 8 characters"
/>
</label>
<button type="submit" class="btn preset-filled-primary-500 w-full">
Create Account
</button>
</form>
<p class="text-center mt-4 text-sm">
Already have an account?
<a href="/login" class="anchor">Login</a>
</p>
</div>
</div>
</div>
EOF

Create Login Page

Create src/routes/login/+page.svelte:

Terminal window
cat > src/routes/login/+page.svelte << 'EOF'
<script lang="ts">
import { enhance } from '$app/forms';
import { page } from '$app/stores';
import type { ActionData } from './$types';
export let form: ActionData;
$: registered = $page.url.searchParams.get('registered');
</script>
<div class="container mx-auto px-4 py-16">
<div class="max-w-md mx-auto">
<div class="card preset-filled-surface-100-900 p-8">
<h1 class="h1 text-3xl font-bold mb-6 text-center">Login</h1>
{#if registered}
<div class="alert preset-filled-success-500 mb-4">
<p>Account created! Please login.</p>
</div>
{/if}
{#if form?.error}
<div class="alert preset-filled-error-500 mb-4">
<p>{form.error}</p>
</div>
{/if}
<form method="POST" use:enhance class="space-y-4">
<label class="label">
<span class="label-text">Email</span>
<input
class="input"
type="email"
name="email"
value={form?.email || ''}
required
placeholder="Enter your email"
/>
</label>
<label class="label">
<span class="label-text">Password</span>
<input
class="input"
type="password"
name="password"
required
placeholder="Enter your password"
/>
</label>
<button type="submit" class="btn preset-filled-primary-500 w-full">
Login
</button>
</form>
<p class="text-center mt-4 text-sm">
Don't have an account?
<a href="/register" class="anchor">Register</a>
</p>
</div>
</div>
</div>
EOF

Create Protected Dashboard

Create src/routes/dashboard/+page.svelte:

Terminal window
cat > src/routes/dashboard/+page.svelte << 'EOF'
<script lang="ts">
import { enhance } from '$app/forms';
import CreateItemForm from '$lib/components/CreateItemForm.svelte';
import ItemCard from '$lib/components/ItemCard.svelte';
import Pagination from '$lib/components/Pagination.svelte';
import StatsCard from '$lib/components/StatsCard.svelte';
import { toaster } from '$lib/stores/toaster';
import type { PageData, ActionData } from './$types';
export let data: PageData;
export let form: ActionData;
$: if (form?.success) {
toaster.success({
title: 'Success',
description: 'Item saved successfully!'
});
}
$: if (form?.error) {
toaster.error({
title: 'Error',
description: form.error
});
}
</script>
<div class="container mx-auto px-4 py-8">
<div class="mb-6">
<h1 class="h1 text-4xl font-bold mb-2">Dashboard</h1>
<p class="text-surface-600-300">Welcome back, {data.user.name || 'User'}!</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
<StatsCard title="Total Items" value={data.totalCount} />
<StatsCard title="Recent Items" value={data.items.slice(0, 5).length} />
</div>
<div class="card preset-filled-surface-100-900 p-6 mb-8">
<h2 class="h2 text-2xl font-bold mb-4">Create a New Item</h2>
<CreateItemForm />
</div>
<div class="mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="h2 text-2xl font-bold">Your Items</h2>
<p class="text-sm text-surface-600-300">
{data.totalCount} {data.totalCount === 1 ? 'item' : 'items'}
</p>
</div>
{#if data.items.length === 0}
<div class="card preset-filled-surface-200-800 p-12 text-center">
<p class="text-surface-600-300">No items yet. Create your first item above!</p>
</div>
{:else}
<div class="grid gap-4">
{#each data.items as item (item.id)}
<ItemCard {item} />
{/each}
</div>
{#if data.totalPages > 1}
<div class="mt-6">
<Pagination
currentPage={data.currentPage}
totalPages={data.totalPages}
baseUrl="/dashboard"
/>
</div>
{/if}
{/if}
</div>
</div>
EOF

Create Profile Page

Create src/routes/profile/+page.svelte:

Terminal window
cat > src/routes/profile/+page.svelte << 'EOF'
<script lang="ts">
import { enhance } from '$app/forms';
import { toaster } from '$lib/stores/toaster';
import type { PageData, ActionData } from './$types';
export let data: PageData;
export let form: ActionData;
$: if (form?.success) {
toaster.success({
title: 'Success',
description: 'Profile updated successfully!'
});
}
$: if (form?.error) {
toaster.error({
title: 'Error',
description: form.error
});
}
</script>
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<h1 class="h1 text-4xl font-bold mb-8">Profile</h1>
<div class="card preset-filled-surface-100-900 p-8">
<div class="flex items-center gap-4 mb-6">
<div class="avatar">
{#if data.user?.image}
<img src={data.user.image} alt={data.user.name || 'User'} class="w-20 h-20 rounded-full" />
{:else}
<div class="w-20 h-20 rounded-full bg-primary-500 flex items-center justify-center text-white text-2xl font-bold">
{data.user?.name?.charAt(0) || 'U'}
</div>
{/if}
</div>
<div>
<h2 class="h2 text-2xl font-bold">{data.user?.name || 'User'}</h2>
<p class="text-surface-600-300">{data.user?.email}</p>
</div>
</div>
<form method="POST" use:enhance class="space-y-4">
<label class="label">
<span class="label-text">Name</span>
<input
class="input"
type="text"
name="name"
value={data.user?.name || ''}
required
placeholder="Enter your name"
/>
</label>
<label class="label">
<span class="label-text">Avatar URL (optional)</span>
<input
class="input"
type="url"
name="image"
value={data.user?.image || ''}
placeholder="https://example.com/avatar.jpg"
/>
</label>
<button type="submit" class="btn preset-filled-primary-500">
Update Profile
</button>
</form>
</div>
</div>
</div>
EOF

Create UI Components

All components use Skeleton UI primitives for consistency and accessibility.

Create src/lib/components/NavBar.svelte:

Terminal window
mkdir -p src/lib/components && cat > src/lib/components/NavBar.svelte << 'EOF'
<script lang="ts">
import { page } from '$app/stores';
export let user: { name?: string; email: string } | null;
</script>
<nav class="bg-surface-100-900 border-b border-surface-300-700">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between h-16">
<a href="/" class="text-xl font-bold">
SvelteKit Starter
</a>
<div class="flex items-center gap-4">
{#if user}
<a
href="/dashboard"
class="btn btn-sm {$page.url.pathname === '/dashboard' ? 'preset-filled-primary-500' : 'preset-outlined'}"
>
Dashboard
</a>
<a
href="/profile"
class="btn btn-sm {$page.url.pathname === '/profile' ? 'preset-filled-primary-500' : 'preset-outlined'}"
>
Profile
</a>
<form method="POST" action="/api/auth/sign-out">
<button type="submit" class="btn btn-sm preset-outlined">
Sign Out
</button>
</form>
{:else}
<a href="/login" class="btn btn-sm preset-outlined">
Login
</a>
<a href="/register" class="btn btn-sm preset-filled-primary-500">
Register
</a>
{/if}
</div>
</div>
</div>
</nav>
EOF

Item Card Component

Create src/lib/components/ItemCard.svelte:

Terminal window
cat > src/lib/components/ItemCard.svelte << 'EOF'
<script lang="ts">
import { enhance } from '$app/forms';
export let item: {
id: string;
name: string;
description: string | null;
createdAt: Date;
};
let editing = false;
let showDeleteModal = false;
</script>
<div class="card preset-filled-surface-100-900 p-6">
{#if editing}
<form method="POST" action="?/update" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') {
editing = false;
}
await update();
};
}} class="space-y-4">
<input type="hidden" name="id" value={item.id} />
<label class="label">
<span class="label-text">Name</span>
<input
class="input"
type="text"
name="name"
value={item.name}
required
/>
</label>
<label class="label">
<span class="label-text">Description</span>
<textarea
class="textarea"
name="description"
rows="3"
value={item.description || ''}
/>
</label>
<div class="flex gap-2">
<button type="submit" class="btn preset-filled-primary-500 btn-sm">
Save
</button>
<button type="button" class="btn preset-outlined btn-sm" on:click={() => editing = false}>
Cancel
</button>
</div>
</form>
{:else}
<div class="flex justify-between items-start mb-2">
<h3 class="h3 text-xl font-bold">{item.name}</h3>
<span class="text-xs text-surface-600-300">
{new Date(item.createdAt).toLocaleDateString()}
</span>
</div>
{#if item.description}
<p class="text-surface-600-300 mb-4">{item.description}</p>
{/if}
<div class="flex gap-2">
<button class="btn preset-outlined-primary-500 btn-sm" on:click={() => editing = true}>
Edit
</button>
<button class="btn preset-filled-error-500 btn-sm" on:click={() => showDeleteModal = true}>
Delete
</button>
</div>
{/if}
</div>
{#if showDeleteModal}
<div
class="modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
on:click={() => showDeleteModal = false}
on:keydown={(e) => {
if (e.key === 'Escape') {
showDeleteModal = false;
}
}}
tabindex="-1"
>
<div class="card preset-filled-surface-100-900 p-6 max-w-md" on:click|stopPropagation>
<h3 id="delete-modal-title" class="h3 text-xl font-bold mb-4">Delete Item?</h3>
<p class="mb-6">Are you sure you want to delete "{item.name}"? This action cannot be undone.</p>
<div class="flex gap-2 justify-end">
<button class="btn preset-outlined btn-sm" on:click={() => showDeleteModal = false}>
Cancel
</button>
<form method="POST" action="?/delete" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') {
showDeleteModal = false;
}
await update();
};
}}>
<input type="hidden" name="id" value={item.id} />
<button type="submit" class="btn preset-filled-error-500 btn-sm">
Delete
</button>
</form>
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
</style>
EOF

Create Item Form Component

Create src/lib/components/CreateItemForm.svelte:

Terminal window
cat > src/lib/components/CreateItemForm.svelte << 'EOF'
<script lang="ts">
import { enhance } from '$app/forms';
let nameInput: HTMLInputElement;
let descriptionInput: HTMLTextAreaElement;
</script>
<form method="POST" action="?/create" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') {
nameInput.value = '';
descriptionInput.value = '';
}
await update();
};
}} class="space-y-4">
<label class="label">
<span class="label-text">Name</span>
<input
bind:this={nameInput}
class="input"
type="text"
name="name"
required
placeholder="Enter item name"
/>
</label>
<label class="label">
<span class="label-text">Description (optional)</span>
<textarea
bind:this={descriptionInput}
class="textarea"
name="description"
rows="3"
placeholder="Enter item description"
/>
</label>
<button type="submit" class="btn preset-filled-primary-500">
Create Item
</button>
</form>
EOF

Pagination Component

Create src/lib/components/Pagination.svelte:

Terminal window
cat > src/lib/components/Pagination.svelte << 'EOF'
<script lang="ts">
export let currentPage: number;
export let totalPages: number;
export let baseUrl: string;
$: prevPage = currentPage > 1 ? currentPage - 1 : null;
$: nextPage = currentPage < totalPages ? currentPage + 1 : null;
</script>
<div class="flex items-center justify-center gap-4">
{#if prevPage}
<a href="{baseUrl}?page={prevPage}" class="btn preset-outlined btn-sm">
Previous
</a>
{:else}
<button class="btn preset-outlined btn-sm" disabled>
Previous
</button>
{/if}
<span class="text-sm">
Page {currentPage} of {totalPages}
</span>
{#if nextPage}
<a href="{baseUrl}?page={nextPage}" class="btn preset-outlined btn-sm">
Next
</a>
{:else}
<button class="btn preset-outlined btn-sm" disabled>
Next
</button>
{/if}
</div>
EOF

Stats Card Component

Create src/lib/components/StatsCard.svelte:

Terminal window
cat > src/lib/components/StatsCard.svelte << 'EOF'
<script lang="ts">
export let title: string;
export let value: number;
</script>
<div class="card preset-filled-surface-100-900 p-6">
<h3 class="text-lg text-surface-600-300 mb-2">{title}</h3>
<p class="text-4xl font-bold">{value}</p>
</div>
EOF

Create GitHub Repository and Push Code

Install GitHub CLI if you haven’t already. Download and install from:

https://cli.github.com/

Restart your terminal after installation.

Authenticate with GitHub:

Terminal window
gh auth login

Initialize Git repository:

Terminal window
git init

Add all changes and make initial commit:

Terminal window
git add .
Terminal window
git commit -m "Initial commit: SvelteKit + PostgreSQL + Prisma + Better Auth"

Create GitHub repository and push:

Terminal window
gh repo create --private --source=. --push

Prefer VS Code? Use Source Control panel → Publish to GitHub → Follow prompts

Deploy to Railway

Connect GitHub Repository

In Railway dashboard:

  1. Click your project
  2. Click “Create”
  3. Select “GitHub Repo”
  4. Choose your repository
  5. Railway auto-detects SvelteKit and configures build settings

After connecting your GitHub repository, Railway creates a SvelteKit service. Now link the database:

  1. In Railway dashboard, click your SvelteKit service
  2. Go to “Variables” tab
  3. Click “Add Variable” next to where it says “Trying to connect a database?”
  4. From the list of available variables, select DATABASE_URL from your Postgres service
  5. Click “Add”

Trigger Initial Deployment

Trigger the initial deployment:

  1. Click the “Deploy” button to start the deployment

Note: This initial deployment will fail because the required environment variables (BETTER_AUTH_SECRET and BETTER_AUTH_URL) are not yet configured. This is expected - we’ll configure them after generating the domain.

Generate Domain

Generate a domain for your application:

  1. In Railway dashboard, click your SvelteKit service
  2. Go to SettingsNetworking
  3. Click “Generate Domain”
  4. When prompted “Enter the port your app is listening on”, enter 8080
  5. Copy the generated domain (e.g., your-project-production.up.railway.app)

Save this domain - you’ll need it for the BETTER_AUTH_URL configuration.

Configure Environment Variables

Configure Better Auth environment variables.

Generate your Better Auth secret by running this command in your local project directory:

Terminal window
openssl rand -base64 32

Go to your Railway dashboard → Variables tab of your SvelteKit service and add:

VariableValue
BETTER_AUTH_SECRETPaste the output from the openssl command
BETTER_AUTH_URLUse your domain from the previous step with https:// prefix (e.g., https://your-project-production.up.railway.app)

Configure Pre-Deploy Command

Railway auto-detects SvelteKit and handles the build automatically. We need to run database migrations before the application starts.

In Railway dashboard:

  1. Go to your SvelteKit service → “Settings”“Deploy”
  2. Scroll down to the “Deploy” section
  3. Click “Add Pre-Deploy step” and enter:
Terminal window
for i in 1 2 3 4 5; do npx prisma migrate deploy && exit 0; sleep $((i * 5)); done; exit 1

This command runs before your app starts and:

  • Applies all pending migrations to the database
  • Automatically retries up to 5 times if the database is starting up (with increasing delays: 5s, 10s, 15s, 20s, 25s)
  • Stops deployment if migrations fail after all retries (preventing broken deployments)

Why Pre-Deploy Command? Pre-deploy commands run in a separate container before your app starts, ensuring migrations complete successfully before traffic reaches your application. If migrations fail, the deployment is cancelled automatically.

Why Retry Logic? Railway PostgreSQL services can go to sleep due to inactivity. When they wake up, they may return “the database system is starting up” errors. The retry logic waits for the database to fully wake up before proceeding, preventing deployment failures from transient startup states.

After configuring environment variables and the pre-deploy command, click the “Deploy” button again to trigger a successful deployment.

Your SvelteKit application is now fully deployed and accessible at your Railway domain!

Next Steps

Build Your Application

Now that you have a working SvelteKit starter template deployed on Railway, here’s how to build upon it:

  1. Replace or Extend the Item Model: The Item model is intentionally generic as a demonstration. You can:

    • Keep it and add your own models alongside it
    • Rename it to match your domain (requires updating schema.prisma, running migrations, and updating form actions and components)
    • Remove it entirely and create your own models from scratch
    • Use it as a reference pattern for building your own CRUD features
  2. Add Your Own Models: Create new models in schema.prisma and run migrations with npx prisma migrate dev

  3. Customize Components: Modify Skeleton UI components and theme to match your design

  4. Add More Features: File uploads, email integration, roles & permissions, real-time updates, API endpoints

  5. Improve UX: Add advanced form validation (Superforms + Zod), loading states, optimistic UI updates, accessibility improvements

  6. Security & Performance: Implement rate limiting on auth endpoints, input sanitization, caching strategies, error monitoring (Sentry), testing (Vitest, Playwright)

Summary

You’ve successfully created a production-ready SvelteKit starter template with:

  • ✅ SvelteKit with file-based routing and form actions
  • ✅ PostgreSQL database on Railway
  • ✅ Prisma ORM for type-safe database access
  • ✅ Better Auth session-based authentication
  • ✅ User authentication and profile management
  • ✅ Item CRUD operations with pagination
  • ✅ Skeleton UI for polished, accessible design
  • ✅ Toast notifications for user feedback
  • ✅ Dashboard with stats overview

This template provides a solid foundation for building any SvelteKit application with modern tools and best practices.


Don’t have Railway credits yet? Sign up with our referral link to get $20 in credits - enough for a full month on the Pro tier!