End-to-end type-safe full-stack app with SvelteKit, tRPC, and Prisma. Built in 2024 as a real alternative to Next.js + tRPC for teams that want Svelte's simplicity without giving up type safety across the client-server boundary.
SvelteKit's server-side load functions and form actions already give you type safety on the server side. But the moment you need a flexible RPC pattern — batching, subscriptions, per-procedure auth — you need tRPC. The official trpc-sveltekit integration existed but was under-documented for production use in 2024.
This repo is the complete, production-annotated version of that integration, with the adapter gotchas solved.
trpc-sveltekit forces Node adapter — no edge runtimetRPC requires AsyncLocalStorage for context propagation, which isn't available on Cloudflare Workers or Vercel Edge. This means you're locked to @sveltejs/adapter-node or adapter-vercel with Node runtime. Fix: accept the constraint, document it, and use adapter-node with a multi-region deployment strategy instead of edge functions.
SvelteKit's Vite-based dev server hot-reloads modules, which re-instantiates PrismaClient on every change. Prisma warns about too many active connections and eventually crashes. Fix: use the singleton pattern in src/lib/server/db.ts — check globalThis.__prisma before creating a new client, only in development.
// src/lib/server/db.ts
const globalForPrisma = globalThis as unknown as { __prisma: PrismaClient }
export const prisma = globalForPrisma.__prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.__prisma = prisma
localsThe createContext function in tRPC doesn't automatically receive SvelteKit's event.locals (where your session lives). Fix: pass event from the SvelteKit handler into createContext, then extract the session from it — see src/lib/server/trpc.ts.
+page.tsImporting the tRPC router type on the client side pulls in server-only Prisma types, causing bundle bloat and Vite errors about Node built-ins. Fix: never import AppRouter directly in +page.ts files. Use the trpc client proxy — the inferred types flow through without importing server code.
invalidateQuery not triggering SvelteKit's reactive $page.datatRPC's invalidateQuery doesn't interop with SvelteKit's invalidate(). After a mutation, $page.data loaded from a load function won't refresh automatically. Fix: call invalidate('trpc:all') from SvelteKit's invalidate API alongside trpc.utils.invalidate(), or migrate data fetching fully to tRPC queries.
| Layer | Technology |
|---|---|
| Framework | SvelteKit 2.5 |
| RPC | tRPC 11 |
| ORM | Prisma 5.14 + PostgreSQL 16 |
| Auth | Lucia v3 (session-based) |
| Styling | Tailwind CSS 3.4 |
| Validation | Zod 3.23 |
| Adapter | @sveltejs/adapter-node |
| Testing | Vitest + Playwright |
src/
├── lib/
│ ├── server/
│ │ ├── db.ts # Prisma singleton
│ │ ├── trpc.ts # tRPC init + context creation
│ │ └── routers/
│ │ ├── index.ts # Root router — merges all sub-routers
│ │ ├── user.ts
│ │ └── post.ts
│ └── client/
│ └── trpc.ts # Browser-side tRPC client proxy
├── routes/
│ ├── api/trpc/[...trpc]/ # tRPC HTTP handler
│ │ └── +server.ts
│ ├── auth/ # Lucia auth routes
│ └── dashboard/
│ ├── +layout.server.ts # Session guard
│ └── +page.svelte
prisma/
├── schema.prisma
└── migrations/
git clone https://github.com/yourusername/sveltekit-trpc
cd sveltekit-trpc
pnpm install
# Start PostgreSQL
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:16
# Set up .env
echo 'DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sveltekit_trpc"' > .env
# Run migrations and generate client
pnpm prisma migrate dev
pnpm prisma generate
# Dev server
pnpm dev
// src/lib/server/routers/post.ts
export const postRouter = router({
list: publicProcedure
.input(z.object({ cursor: z.string().optional() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.post.findMany({
take: 20,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
})
}),
create: protectedProcedure
.input(z.object({ title: z.string().min(1).max(200), body: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.post.create({
data: { ...input, authorId: ctx.session.userId },
})
}),
})
$effect rune (Svelte 5) interacts unexpectedly with tRPC's query invalidation — use $derived for reactive query data insteadMIT