Build a Full-Stack SvelteKit Starter Template with PostgreSQL, Prisma, Skeleton UI, and Better Auth
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
npx sv create sv-pg-prismaWhen 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
Navigate to Project Directory
cd sv-pg-prismaInstall Dependencies
Install all required packages for authentication and database access:
npm install better-authnpm install @prisma/client@^5.22.0npm install -D prisma@^5.22.0 @types/nodeNote: 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:
npm install -D @skeletonlabs/skeleton @skeletonlabs/skeleton-svelteConfigure Skeleton Styles
Create src/app.css to add Skeleton imports:
cat > src/app.css << 'EOF'@import 'tailwindcss';@import '@skeletonlabs/skeleton';@import '@skeletonlabs/skeleton/themes/cerberus';EOFSet the Active Theme
Create src/app.html to set the active theme:
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>EOFThis 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
npm install -g @railway/cliRestart 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
railway loginCreate Railway Project
railway initWhen prompted:
- Select a workspace: Choose your workspace
- Project Name: Empty Project for a randomly generated name or
sv-pg-prisma
Add PostgreSQL Database
railway add -d postgresThis creates a PostgreSQL database and links it to your Railway project.
Link Your Local Project to Railway
railway linkWhen prompted, make the following selections:
- Select a workspace: Choose your workspace
- Select a project: Choose the project you just created
- Select an environment: Choose
production - Select a service: Choose
Postgres
Note: If you don’t see “Select a service” appear, you may need to run
railway linkagain to ensure the Postgres service is properly linked.
Configure Prisma
Create Prisma Schema
Create the Prisma directory and schema file:
mkdir -p prismacat > 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])}EOFThis 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:
railway variables --service Postgres --json | grep DATABASE_PUBLIC_URLOnce you have the DATABASE_PUBLIC_URL, update your .env file:
cat > .env << 'EOF'DATABASE_URL="<paste your DATABASE_PUBLIC_URL here>?sslmode=require"EOFReplace <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:
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;EOFThis 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:
npx prisma migrate dev --name initThis command:
- Uses the
DATABASE_URLyou just set - Creates a
prisma/migrationsdirectory 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:
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)],});EOFThis configuration:
- Validates that
BETTER_AUTH_SECRETis 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
sveltekitCookiesplugin
Create Type Definitions
Update src/app.d.ts to add type safety for authenticated user data:
cat > src/app.d.ts << 'EOF'// See https://kit.svelte.dev/docs/types#app// for information about these interfacesdeclare 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 {};EOFCreate Server Hooks
Create src/hooks.server.ts to handle authentication and validate sessions on every request:
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 });};EOFThis hook:
- Runs on every request
- Fetches and validates the session from Better Auth
- Attaches user and session to
event.localsfor use throughout your app - Automatically handles all
/api/auth/*routes viasvelteKitHandler - 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:
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'); },};EOFCreate Login Action
Create the login page with form action:
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'); },};EOFCreate Item CRUD Actions
Create form actions for managing items in the dashboard:
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' }); } },};EOFCreate Profile Update Action
Create form action for updating user profile:
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' }); } },};EOFBuild the Application
Create Toaster Store
Create a shared toaster instance for toast notifications:
mkdir -p src/lib/stores && cat > src/lib/stores/toaster.ts << 'EOF'import { createToaster } from '@skeletonlabs/skeleton-svelte';
export const toaster = createToaster({});EOFCreate Root Layout
Create src/routes/+layout.svelte to set up the global layout with navigation:
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>EOFCreate the corresponding server load function:
cat > src/routes/+layout.server.ts << 'EOF'import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => { return { user: locals.user, };};EOFCreate Landing Page
Create the server load function for the landing page:
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 {};};EOFCreate src/routes/+page.svelte for unauthenticated users:
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>EOFCreate Registration Page
Create src/routes/register/+page.svelte:
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>EOFCreate Login Page
Create src/routes/login/+page.svelte:
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>EOFCreate Protected Dashboard
Create src/routes/dashboard/+page.svelte:
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>EOFCreate Profile Page
Create src/routes/profile/+page.svelte:
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>EOFCreate UI Components
All components use Skeleton UI primitives for consistency and accessibility.
Navigation Bar Component
Create src/lib/components/NavBar.svelte:
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>EOFItem Card Component
Create src/lib/components/ItemCard.svelte:
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>EOFCreate Item Form Component
Create src/lib/components/CreateItemForm.svelte:
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>EOFPagination Component
Create src/lib/components/Pagination.svelte:
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>EOFStats Card Component
Create src/lib/components/StatsCard.svelte:
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>EOFCreate GitHub Repository and Push Code
Install GitHub CLI if you haven’t already. Download and install from:
Restart your terminal after installation.
Authenticate with GitHub:
gh auth loginInitialize Git repository:
git initAdd all changes and make initial commit:
git add .git commit -m "Initial commit: SvelteKit + PostgreSQL + Prisma + Better Auth"Create GitHub repository and push:
gh repo create --private --source=. --pushPrefer VS Code? Use Source Control panel → Publish to GitHub → Follow prompts
Deploy to Railway
Connect GitHub Repository
In Railway dashboard:
- Click your project
- Click “Create”
- Select “GitHub Repo”
- Choose your repository
- Railway auto-detects SvelteKit and configures build settings
Link Database to SvelteKit Service
After connecting your GitHub repository, Railway creates a SvelteKit service. Now link the database:
- In Railway dashboard, click your SvelteKit service
- Go to “Variables” tab
- Click “Add Variable” next to where it says “Trying to connect a database?”
- From the list of available variables, select
DATABASE_URLfrom your Postgres service - Click “Add”
Trigger Initial Deployment
Trigger the initial deployment:
- Click the “Deploy” button to start the deployment
Note: This initial deployment will fail because the required environment variables (
BETTER_AUTH_SECRETandBETTER_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:
- In Railway dashboard, click your SvelteKit service
- Go to Settings → Networking
- Click “Generate Domain”
- When prompted “Enter the port your app is listening on”, enter
8080 - 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:
openssl rand -base64 32Go to your Railway dashboard → Variables tab of your SvelteKit service and add:
| Variable | Value |
|---|---|
BETTER_AUTH_SECRET | Paste the output from the openssl command |
BETTER_AUTH_URL | Use 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:
- Go to your SvelteKit service → “Settings” → “Deploy”
- Scroll down to the “Deploy” section
- Click “Add Pre-Deploy step” and enter:
for i in 1 2 3 4 5; do npx prisma migrate deploy && exit 0; sleep $((i * 5)); done; exit 1This 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:
-
Replace or Extend the Item Model: The
Itemmodel 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
-
Add Your Own Models: Create new models in
schema.prismaand run migrations withnpx prisma migrate dev -
Customize Components: Modify Skeleton UI components and theme to match your design
-
Add More Features: File uploads, email integration, roles & permissions, real-time updates, API endpoints
-
Improve UX: Add advanced form validation (Superforms + Zod), loading states, optimistic UI updates, accessibility improvements
-
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!