Wiring Prisma 7 into TanStack Start on Bun
Wiring Prisma 7 into TanStack Start on Bun
This site runs on TanStack Start with Prisma 7 against PostgreSQL, on the Bun runtime. The three of them fit together cleanly, but there are a handful of small decisions you want to get right before the code lands in production. These are the ones I'd want written down.
prisma.config.ts is where the datasource lives now
Prisma 7 moved the runtime datasource configuration out of schema.prisma and into a prisma.config.ts file at the repo root. The schema still declares the provider; the URL does not belong there anymore.
1import { defineConfig } from 'prisma/config'
2
3export default defineConfig({
4 schema: 'prisma/schema.prisma',
5 migrations: { path: 'prisma/migrations' },
6 datasource: { url: process.env.DATABASE_URL! },
7})Bun loads .env into process.env before the CLI evaluates this config, so bunx prisma migrate dev and bunx prisma generate see the URL without any extra flags or dotenv-cli wrapping. If you skip the config file and put url = env("DATABASE_URL") in the datasource block the way Prisma 6 wanted, the v7 CLI won't read it.
The driver adapter, on Bun
Prisma 7's driver-adapter API lets you plug in a native Postgres driver instead of going through the Rust query engine for the actual connection. On Bun talking to Postgres, @prisma/adapter-pg is the shortest path:
1import { PrismaClient } from '@prisma/client'
2import { PrismaPg } from '@prisma/adapter-pg'
3
4function createPrismaClient() {
5 const connectionString = process.env.DATABASE_URL
6 if (!connectionString) {
7 throw new Error('DATABASE_URL environment variable is not set')
8 }
9 const adapter = new PrismaPg({ connectionString })
10 return new PrismaClient({ adapter })
11}The win is that connection behaviour — pooling, SSL, timeouts — reduces to plain pg knowledge. I'm still on the prisma-client-js generator, so the query engine binary is part of the install; fully engine-free operation would mean opting into the newer prisma-client generator. That's a separate decision from the adapter.
Singleton in dev, fresh client in prod
Vite's HMR will happily re-evaluate the module that owns your Prisma client on every file change. Without guardrails, that's a new PrismaClient (and a new connection pool) every save, until Postgres starts rejecting connections.
The stash-on-globalThis pattern solves it in five lines:
1const globalForPrisma = globalThis as unknown as {
2 prisma: PrismaClient | undefined
3}
4
5export const prisma = globalForPrisma.prisma ?? createPrismaClient()
6
7if (process.env.NODE_ENV !== 'production') {
8 globalForPrisma.prisma = prisma
9}In production the guard does nothing. In dev, the second evaluation finds the existing client and reuses its pool. Standard pattern, but worth doing from day one rather than after you've debugged "too many connections" at 11pm.
snake_case in the database, camelCase in TypeScript
Prisma's @map and @@map translate between the two without any runtime cost:
1model Post {
2 id Int @id @default(autoincrement())
3 title String
4 slug String @unique
5 publishedAt DateTime? @map("published_at")
6 contentMarkdown String? @map("content_markdown") @db.Text
7 createdAt DateTime @default(now()) @map("created_at")
8 updatedAt DateTime @updatedAt @map("updated_at")
9
10 @@map("posts")
11}The generated client gives me post.publishedAt; the migration SQL gives psql users published_at. Everyone gets the ergonomics they expect.
Server functions are where Prisma actually lives
TanStack Start's createServerFn is the hinge. A server function runs only on the server, so you can call Prisma freely and return a plain object. The client gets a typed RPC, not a database handle:
1// simplified — real code also returns url/source for the wire format
2import { createServerFn } from '@tanstack/react-start'
3import { prisma } from './db'
4
5export const fetchLocalPostBySlug = createServerFn({ method: 'GET' }).handler(
6 async ({ data: slug }: { data: string }) => {
7 const post = await prisma.post.findUnique({
8 where: { slug },
9 include: { tags: { include: { tag: true } } },
10 })
11 if (!post) return null
12 return {
13 id: post.id,
14 title: post.title,
15 slug: post.slug,
16 tags: post.tags.map((pt) => pt.tag.name),
17 publishedAt: post.publishedAt?.toISOString() ?? null,
18 contentMarkdown: post.contentMarkdown,
19 views: post.views,
20 }
21 }
22)Routes consume that from a loader and the page renders with the data already in hand:
1export const Route = createFileRoute('/posts/$slug')({
2 loader: async ({ params }) => {
3 const post = await fetchLocalPostBySlug({ data: params.slug })
4 if (!post) throw notFound()
5 return { post }
6 },
7 component: PostPage,
8})No useEffect, no loading spinner, no /api/posts/:slug route to hand-roll. The router waits for the loader, SSR renders with real data, and the client hydrates into the same tree.
Keep Prisma out of the client bundle
This is the one I got wrong first.
If a module that imports prisma is also imported — even transitively, even from a file that only exports types — by something that ends up in the client graph, Vite will try to bundle it. The usual outcome is a build error when Vite hits Node built-ins inside @prisma/client; occasionally it's a silently bloated client chunk. Either way, not what you want.
Start's Vite plugin already handles the obvious case: it rewrites createServerFn(...).handler(fn) so the client sees an RPC stub, not your handler body. Put Prisma imports inside handlers and you're fine.
Where it gets awkward is when you want a shared service module — called from multiple server functions, occasionally referenced for its types on the client side. The trick is to keep the database module off the synchronous import graph:
1import type { PrismaClient } from '@prisma/client'
2
3async function getPrisma(): Promise<PrismaClient> {
4 const mod = await import('./db')
5 return mod.prisma
6}
7
8export async function createPost(input: CreatePostInput) {
9 const prisma = await getPrisma()
10 // ...
11}The dynamic import('./db') means Vite splits ./db into its own async chunk instead of pulling it into whatever synchronously imported this file. Since these service functions only get called from server contexts, that async chunk never actually loads in the browser. Types flow through import type, which is stripped at build time. Client bundle stays clean.
What you get
Data fetching looks like calling a typed function, the database schema is the source of truth for both the API and the TypeScript types, and the seams between server and client stay narrow enough that you rarely notice them. The patterns above are all small — picking them before production tells you to is the trick.