A production-ready fullstack TypeScript starter with end-to-end type safety, authentication, and a PostgreSQL database.
| Layer | Library | Version | Purpose |
|---|---|---|---|
| Framework | SvelteKit 5 | ^2.57 | Routing, SSR, file-based pages |
| Language | TypeScript | ^6 | End-to-end type safety |
| Styling | Tailwind CSS v4 | ^4.2 | Utility-first CSS |
| UI Components | shadcn-svelte (bits-ui) | ^1.2 | Accessible, composable components |
| ORM | Drizzle ORM | ^0.45 | Type-safe SQL queries |
| Database | PostgreSQL | — | Relational database (Docker locally) |
| Auth | Better Auth | ~1.4 | Sessions, email/password, GitHub OAuth |
| API | tRPC v11 | ^11.16 | End-to-end typesafe RPC |
| tRPC adapter | trpc-sveltekit | ^3.6 | SvelteKit-native tRPC client factory |
| Serialization | superjson | ^2.2 | Dates, Maps, Sets over the wire |
| Validation | Zod v4 | ^4.3 | Input schemas for tRPC procedures |
| Runtime | Bun | — | Package manager + script runner |
| Linter/Formatter | Biome | ^2.4 | Fast lint + format, replaces ESLint/Prettier |
# 1. Install dependencies
bun install
# 2. Copy and fill in environment variables
cp .env.example .env
# 3. Start the local PostgreSQL database
bun run db:start
# 4. Apply database migrations
bun run db:migrate
# 5. Start the dev server
bun run dev
.env)DATABASE_URL="postgres://root:mysecretpassword@localhost:5432/local"
ORIGIN="http://localhost:5173"
BETTER_AUTH_SECRET="" # generate: openssl rand -base64 32
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
For GitHub OAuth, create an app at https://github.com/settings/developers with callback URL http://localhost:5173/api/auth/callback/github.
.
├── drizzle/ # Auto-generated SQL migration files
├── docker/
│ └── compose.yaml # Local PostgreSQL via Docker
├── src/
│ ├── app.d.ts # SvelteKit Locals type augmentation (user, session)
│ ├── app.html # Root HTML shell
│ ├── hooks.server.ts # Better Auth session population + request handler
│ └── lib/
│ ├── auth-client.ts # Browser-side Better Auth client (signIn, signUp…)
│ ├── utils.ts # cn() helper (clsx + tailwind-merge)
│ ├── components/ui/ # shadcn-svelte components
│ ├── server/
│ │ ├── auth.ts # Better Auth server config
│ │ ├── db/
│ │ │ ├── index.ts # Drizzle client (postgres.js)
│ │ │ ├── schema.ts # App tables + re-exports auth.schema
│ │ │ └── auth.schema.ts # Better Auth tables (auto-generated)
│ │ └── trpc/
│ │ ├── context.ts # Per-request context (session + user)
│ │ ├── router.ts # initTRPC, publicProcedure, protectedProcedure
│ │ ├── index.ts # appRouter + AppRouter type
│ │ └── routers/
│ │ ├── auth.router.ts # Auth procedures
│ │ └── example.router.ts # Example CRUD procedures
│ └── trpc/
│ ├── client.ts # Browser tRPC client singleton (trpc())
│ └── index.ts # Re-exports trpc() and AppRouter
└── src/routes/
├── +layout.svelte # Root layout (CSS, favicon)
├── +layout.server.ts # Exposes user/session to all pages
├── +page.svelte # Landing page
├── layout.css # Tailwind + shadcn CSS variables + theme
├── (auth)/ # Unauthenticated route group
│ ├── +layout.server.ts # Guard: redirect to /dashboard if logged in
│ ├── +layout.svelte # Centered card layout
│ ├── login/ # /login — email/password + GitHub OAuth
│ ├── register/ # /register — sign up
│ ├── forgot-password/ # /forgot-password — request reset email
│ └── reset-password/ # /reset-password?token=… — set new password
├── (protected)/ # Authenticated route group
│ ├── +layout.server.ts # Guard: redirect to /login if not authenticated
│ ├── +layout.svelte # App shell with navbar + sign-out
│ └── dashboard/ # /dashboard — tRPC demo (greeting + tasks CRUD)
├── api/trpc/[...trpc]/
│ └── +server.ts # tRPC HTTP endpoint (fetchRequestHandler)
└── demo/better-auth/ # Legacy form-action auth demo (reference only)
Auth is handled entirely by Better Auth. The server config lives in src/lib/server/auth.ts and supports email/password and GitHub OAuth out of the box.
hooks.server.ts calls auth.api.getSession() on every request and populates event.locals.user and event.locals.session. All server load functions and form actions can read these directly.
Two SvelteKit route groups handle guards centrally — no per-page boilerplate needed:
(auth)/+layout.server.ts — redirects to /dashboard if the user is already logged in(protected)/+layout.server.ts — redirects to /login if the user is not authenticatedimport { signIn, signUp, signOut, useSession } from "$lib/auth-client";
// Reactive session store
const session = useSession();
// $session.data?.user, $session.isPending
// Sign in
await signIn.email({ email, password, callbackURL: "/dashboard" });
// GitHub OAuth
await signIn.social({ provider: "github", callbackURL: "/dashboard" });
// Sign out
await signOut();
Run this after adding Better Auth plugins:
bun run auth:schema
Procedures are defined in src/lib/server/trpc/routers/. Two procedure types are available:
import { publicProcedure, protectedProcedure, router } from "../router";
export const myRouter = router({
// No auth required
hello: publicProcedure
.input(z.object({ name: z.string() }))
.query(({ input }) => `Hello ${input.name}`),
// Requires a valid session — throws UNAUTHORIZED otherwise
secret: protectedProcedure
.query(({ ctx }) => ctx.user),
});
Register new routers in src/lib/server/trpc/index.ts.
The HTTP endpoint at /api/trpc uses fetchRequestHandler from @trpc/server/adapters/fetch — not createTRPCHandle, which returns a SvelteKit Handle and belongs in hooks.server.ts, not a +server.ts file.
import { trpc } from "$lib/trpc";
// In a Svelte component (browser)
const result = await trpc().example.hello.query({ name: "World" });
// In a +page.ts load function (SSR-compatible)
export const load = async (event) => {
return { tasks: await trpc(event).example.getTasks.query() };
};
The client is a singleton in the browser. Pass the SvelteKit event object for SSR-compatible fetching in load functions.
Components are from shadcn-svelte, scaffolded with:
bunx shadcn-svelte@next init --preset b3yzKzMcEc
Always import from the barrel file to get full variant support:
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Card, CardHeader, CardTitle, CardContent, CardFooter, CardDescription } from "$lib/components/ui/card/index.js";
Add more components:
bunx shadcn-svelte@next add <component-name>
The theme is defined in src/routes/layout.css using OKLCH CSS variables and supports dark mode via the .dark class.
App tables go in src/lib/server/db/schema.ts. Auth tables are auto-generated in auth.schema.ts and re-exported from schema.ts so there's a single import point.
bun run db:generate <name> # generate migration from schema diff
bun run db:migrate # apply pending migrations
bun run db:push # push schema directly (dev shortcut)
bun run db:studio # open Drizzle Studio
| Script | Description |
|---|---|
bun run dev |
Start dev server |
bun run build |
Production build |
bun run check |
Type check (svelte-check) |
bun run db:start |
Start local PostgreSQL via Docker |
bun run db:generate <name> |
Generate a new migration |
bun run db:migrate |
Apply pending migrations |
bun run db:push |
Push schema directly (dev only) |
bun run db:studio |
Open Drizzle Studio |
bun run auth:schema |
Regenerate Better Auth schema file |
| Decision | Rationale |
|---|---|
better-auth/minimal import |
Smaller bundle — only includes what's used |
fetchRequestHandler for tRPC endpoint |
createTRPCHandle returns a Handle for hooks, not a RequestHandler for +server.ts |
Route groups (auth) / (protected) |
Centralises guards in layout files — no per-page boilerplate |
Named exports from auth-client.ts |
Tree-shakeable — avoids importing the full client object everywhere |
| superjson configured on server only | tRPC v11 transformer is set once on initTRPC; trpc-sveltekit client inherits it automatically |
auth.schema.ts separate from schema.ts |
Keeps auto-generated code isolated; re-exported for a single import point |
| Biome over ESLint/Prettier | Single tool, faster, zero config conflicts |