Databases & ORMs in Next.js
Every non-trivial application needs durable state. In Next.js, the question is not just which database or ORM to choose, but where data access runs in the first place. With React Server Components and the App Router, the default answer is on the server: the component renders where the database connection lives, and the browser receives only the final HTML or JSON payload.
Next.js does not prescribe a specific ORM or driver. It gives you a runtime environment where Server Components can be async, where Server Actions can mutate data securely, and where caching and revalidation are first-class primitives. The database layer is still your choice: Prisma for schema-first ergonomics, Drizzle for type-safe SQL, TypeORM for existing Node.js projects, or raw drivers when you need minimal overhead.
This guide is about integrating databases and ORMs into Next.js at production scale. You will learn how to query inside Server Components, configure Prisma and Drizzle, run mutations through Server Actions, handle connection pooling in serverless environments, choose edge-compatible drivers, and cache query results without serving stale data. The goal is to keep connections healthy, types safe, and secrets off the client.
In the App Router, every page component can be asynchronous. That means a Server Component can fetch data directly from the database without a separate REST or GraphQL layer. The client never sees the connection string, the query string, or the raw ORM client. It receives only the rendered result.
Server Components run on the server during rendering, so they are the ideal place for database reads. They can also be nested: a layout can fetch a user session, a page can fetch a list of posts, and each post card can be a child Server Component that receives the data as props. Because each component has access to the full request context, you can read cookies, validate headers, and pass tenant identifiers straight into the query.
| 1 | // app/page.tsx |
| 2 | import prisma from "@/lib/prisma"; |
| 3 | |
| 4 | export default async function HomePage() { |
| 5 | const posts = await prisma.post.findMany({ |
| 6 | where: { published: true }, |
| 7 | orderBy: { createdAt: "desc" }, |
| 8 | take: 10, |
| 9 | }); |
| 10 | |
| 11 | return ( |
| 12 | <main> |
| 13 | <h1>Latest Posts</h1> |
| 14 | <ul> |
| 15 | {posts.map((post) => ( |
| 16 | <li key={post.id}>{post.title}</li> |
| 17 | ))} |
| 18 | </ul> |
| 19 | </main> |
| 20 | ); |
| 21 | } |
The server-first pattern removes the need for client-side data fetching libraries for most read-only pages. Tools like React Query or SWR are still useful for interactive client components, such as live dashboards and infinite scroll, but they should call server-side route handlers or Server Actions, never the database directly.
note
| Location | When to fetch here | Trade-off |
|---|---|---|
| Server Component | Static or dynamic page data, initial render | Zero client bundle impact, easy caching |
| Route Handler | API consumers, client polling, external systems | Extra HTTP hop, explicit request/response handling |
| Server Action | Form submissions, mutations, optimistic UI | CSRF protected, but runs outside render cycle |
| Client Component | Browser-only interactivity with cached data | Cannot access the database directly |
Prisma is a schema-first ORM with a powerful type generator. It gives you a declarative data model, a migration system, and a type-safe query engine. For Next.js projects, the main workflow is: define a schema, run migrations, generate the client, and use a singleton instance to avoid connection exhaustion during development.
Start with the schema. Keep it in prisma/schema.prisma and point the database URL to an environment variable. A good schema defines explicit relations, indexes, and native types, so that the database enforces the same constraints the TypeScript types imply.
| 1 | // prisma/schema.prisma |
| 2 | |
| 3 | generator client { |
| 4 | provider = "prisma-client-js" |
| 5 | } |
| 6 | |
| 7 | datasource db { |
| 8 | provider = "postgresql" |
| 9 | url = env("DATABASE_URL") |
| 10 | } |
| 11 | |
| 12 | model User { |
| 13 | id String @id @default(uuid()) |
| 14 | email String @unique |
| 15 | name String? |
| 16 | createdAt DateTime @default(now()) @map("created_at") |
| 17 | posts Post[] |
| 18 | |
| 19 | @@map("users") |
| 20 | } |
| 21 | |
| 22 | model Post { |
| 23 | id String @id @default(uuid()) |
| 24 | title String |
| 25 | slug String @unique |
| 26 | published Boolean @default(false) |
| 27 | content String |
| 28 | createdAt DateTime @default(now()) @map("created_at") |
| 29 | updatedAt DateTime @updatedAt @map("updated_at") |
| 30 | author User @relation(fields: [authorId], references: [id]) |
| 31 | authorId String @map("author_id") |
| 32 | |
| 33 | @@map("posts") |
| 34 | } |
The Prisma client must be a singleton. In development, Next.js hot reloads modules and can create new client instances on every request. A module-scoped variable guarded by the global object prevents connection leaks.
| 1 | // lib/prisma.ts |
| 2 | import { PrismaClient } from "@prisma/client"; |
| 3 | |
| 4 | const globalForPrisma = globalThis as unknown as { |
| 5 | prisma: PrismaClient | undefined; |
| 6 | }; |
| 7 | |
| 8 | export const prisma = globalForPrisma.prisma ?? new PrismaClient(); |
| 9 | |
| 10 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; |
| 11 | |
| 12 | export default prisma; |
Use the singleton in Server Components and Server Actions. Reads are straightforward, and the returned types flow into the rest of the component tree. For mutations, wrap writes in a Server Action, validate input with a schema library, and return structured results so the UI can show clear error states.
| 1 | // app/posts/[slug]/page.tsx |
| 2 | import prisma from "@/lib/prisma"; |
| 3 | import { notFound } from "next/navigation"; |
| 4 | |
| 5 | export default async function PostPage({ |
| 6 | params, |
| 7 | }: { |
| 8 | params: Promise<{ slug: string }>; |
| 9 | }) { |
| 10 | const { slug } = await params; |
| 11 | const post = await prisma.post.findUnique({ |
| 12 | where: { slug }, |
| 13 | include: { author: { select: { name: true } } }, |
| 14 | }); |
| 15 | |
| 16 | if (!post) notFound(); |
| 17 | |
| 18 | return ( |
| 19 | <article> |
| 20 | <h1>{post.title}</h1> |
| 21 | <p>By {post.author.name}</p> |
| 22 | <div>{post.content}</div> |
| 23 | </article> |
| 24 | ); |
| 25 | } |
warning
Migrations are run from the CLI. In production, you typically run prisma migrate deploy as part of your build command or deployment pipeline, not inside the application code. Keep the migrate command separate from the server start so that schema changes do not block new pods from serving traffic.
| 1 | # Generate the Prisma client after schema changes |
| 2 | npx prisma generate |
| 3 | |
| 4 | # Create a migration from schema changes |
| 5 | npx prisma migrate dev --name add_post_slug |
| 6 | |
| 7 | # Deploy pending migrations in production |
| 8 | npx prisma migrate deploy |
Drizzle is a type-safe SQL toolkit that stays close to the database. It is often described as an ORM, but it behaves more like a SQL builder: you define schemas with TypeScript, write queries that look like SQL, and still get full type inference. It is smaller than Prisma and tends to be more edge-friendly because it uses lightweight database drivers rather than a native binary engine.
A typical Drizzle setup with PostgreSQL uses the drizzle-orm core and drizzle-kit for migrations, plus the @neondatabase/serverless or pg driver. The schema file is pure TypeScript, which makes it easy to colocate with your application code.
| 1 | // lib/db/schema.ts |
| 2 | import { pgTable, uuid, varchar, boolean, text, timestamp } from "drizzle-orm/pg-core"; |
| 3 | import { relations } from "drizzle-orm"; |
| 4 | |
| 5 | export const users = pgTable("users", { |
| 6 | id: uuid("id").primaryKey().defaultRandom(), |
| 7 | email: varchar("email", { length: 255 }).notNull().unique(), |
| 8 | name: varchar("name", { length: 255 }), |
| 9 | createdAt: timestamp("created_at").defaultNow().notNull(), |
| 10 | }); |
| 11 | |
| 12 | export const posts = pgTable("posts", { |
| 13 | id: uuid("id").primaryKey().defaultRandom(), |
| 14 | title: varchar("title", { length: 255 }).notNull(), |
| 15 | slug: varchar("slug", { length: 255 }).notNull().unique(), |
| 16 | published: boolean("published").default(false).notNull(), |
| 17 | content: text("content").notNull(), |
| 18 | authorId: uuid("author_id") |
| 19 | .notNull() |
| 20 | .references(() => users.id), |
| 21 | createdAt: timestamp("created_at").defaultNow().notNull(), |
| 22 | updatedAt: timestamp("updated_at").defaultNow().notNull(), |
| 23 | }); |
| 24 | |
| 25 | export const usersRelations = relations(users, ({ many }) => ({ |
| 26 | posts: many(posts), |
| 27 | })); |
| 28 | |
| 29 | export const postsRelations = relations(posts, ({ one }) => ({ |
| 30 | author: one(users, { fields: [posts.authorId], references: [users.id] }), |
| 31 | })); |
Initialize the connection once and reuse it. Drizzle uses a database connection object, so the same singleton pattern applies. With a serverless HTTP driver like Neon, the connection is stateless and edge-compatible; with a TCP driver like pg, you need to manage pooling and connection limits.
| 1 | // lib/db/index.ts |
| 2 | import { drizzle } from "drizzle-orm/neon-http"; |
| 3 | import { neon } from "@neondatabase/serverless"; |
| 4 | import * as schema from "./schema"; |
| 5 | |
| 6 | const sql = neon(process.env.DATABASE_URL!); |
| 7 | export const db = drizzle(sql, { schema }); |
| 8 | |
| 9 | export default db; |
Queries in Server Components look like SQL but are fully typed. You can select, filter, order, and join with explicit relations. The query builder returns plain objects, so they serialize over the component boundary without extra mapping.
| 1 | // app/posts/page.tsx |
| 2 | import db from "@/lib/db"; |
| 3 | |
| 4 | export default async function PostsPage() { |
| 5 | const publishedPosts = await db.query.posts.findMany({ |
| 6 | where: (posts, { eq }) => eq(posts.published, true), |
| 7 | orderBy: (posts, { desc }) => [desc(posts.createdAt)], |
| 8 | limit: 10, |
| 9 | with: { |
| 10 | author: { columns: { name: true } }, |
| 11 | }, |
| 12 | }); |
| 13 | |
| 14 | return ( |
| 15 | <ul> |
| 16 | {publishedPosts.map((post) => ( |
| 17 | <li key={post.id}>{post.title} by {post.author.name}</li> |
| 18 | ))} |
| 19 | </ul> |
| 20 | ); |
| 21 | } |
Drizzle Kit generates migrations from your schema. After changing the schema, run the generate command and apply the generated SQL. Keep the drizzle folder in version control so that every environment applies the same migrations.
| 1 | # Generate migrations from schema changes |
| 2 | npx drizzle-kit generate |
| 3 | |
| 4 | # Apply migrations in development or production |
| 5 | npx drizzle-kit migrate |
| 6 | |
| 7 | # Push changes directly during early prototyping |
| 8 | npx drizzle-kit push |
info
TypeORM is a mature decorator-driven ORM with a large feature set. It supports migrations, subscribers, repositories, and complex relations. It is a good fit when your team is already familiar with it, or when you need an ORM with a more traditional object-oriented API. However, TypeORM can be heavy and is not always ideal for serverless environments where connection management is critical.
MikroORM is another alternative that emphasizes unit of work and identity map patterns. It is excellent for complex domains and gives you strong control over object identity and lazy loading. Kysely sits at the opposite end of the spectrum: it is a type-safe SQL query builder, not an ORM, and is perfect for teams that prefer explicit SQL over generated queries.
| Tool | Style | Best for | Serverless notes |
|---|---|---|---|
| Prisma | Schema-first ORM | Rapid development, rich migrations | Use Accelerate or driver adapters on the edge |
| Drizzle | Type-safe SQL | Performance, edge deployments, SQL lovers | HTTP drivers are edge-friendly |
| TypeORM | Decorator-driven ORM | Existing TypeORM codebases, complex domains | Connection pooling is required |
| MikroORM | Unit of work ORM | Rich domain models, identity map | Careful with lazy loading in serverless |
| Kysely | Type-safe SQL builder | Explicit SQL, existing migrations | Thin layer, no hidden connection pooling |
| Raw driver | Direct SQL | Maximum control, minimal dependencies | You manage pooling and prepared statements |
Raw SQL is not an admission of defeat. For analytical queries, heavy aggregations, or migrations that are hard to express in an ORM, a raw driver or a query builder is often clearer. The best stack is usually a hybrid: an ORM for CRUD operations and migrations, and a query builder for performance-critical paths.
best practice
The Edge Runtime in Next.js is lightweight and has no Node.js TCP networking stack. A database driver that opens a long-lived TCP connection will not work on the edge. Instead, edge-compatible databases expose an HTTP API that can be called with fetch, making them ideal for serverless functions and Vercel Edge Functions.
Neon, PlanetScale, Turso, Vercel Postgres, and Supabase all offer HTTP access. Neon and Vercel Postgres are PostgreSQL-compatible with a serverless driver. PlanetScale is MySQL-compatible through its serverless driver. Turso is libSQL-based and runs SQLite at the edge. Supabase provides a REST API through PostgREST and can be accessed from the edge with row-level security.
| Database | Protocol | Next.js integration |
|---|---|---|
| Neon | HTTP + WebSocket | @neondatabase/serverless, drizzle-orm/neon-http |
| PlanetScale | HTTP over MySQL | @planetscale/database, drizzle-orm/planetscale-serverless |
| Turso | HTTP | @libsql/client, drizzle-orm/libsql |
| Vercel Postgres | HTTP | @vercel/postgres, kysely-vercel-postgres |
| Supabase | REST / Realtime | @supabase/supabase-js, server-side client with service key |
When you choose an edge database, you are not just choosing a protocol; you are choosing a deployment model where connections are short-lived and cold starts matter. HTTP drivers are stateless and pay a small per-query latency cost, while TCP drivers offer lower latency and richer session features but require pooling infrastructure.
| 1 | // app/api/edge-posts/route.ts |
| 2 | import { NextResponse } from "next/server"; |
| 3 | import { neon } from "@neondatabase/serverless"; |
| 4 | |
| 5 | export const runtime = "edge"; |
| 6 | |
| 7 | export async function GET() { |
| 8 | const sql = neon(process.env.DATABASE_URL!); |
| 9 | const rows = await sql('SELECT id, title, created_at FROM posts WHERE published = true ORDER BY created_at DESC LIMIT 10'); |
| 10 | |
| 11 | return NextResponse.json({ posts: rows }); |
| 12 | } |
warning
In a traditional server, an application opens a pool of database connections at startup and reuses them for the lifetime of the process. In a serverless function, every request may run in a fresh process, and each process can open its own connections. Without pooling, a traffic spike can exhaust the database connection limit and cause cascading failures.
Connection pooling solves this by placing a proxy between the application and the database. PgBouncer is the classic PostgreSQL connection pooler. Modern managed services abstract this away: Neon, Supabase, and Vercel Postgres all provide connection pooling as part of their pooled connection strings. For AWS RDS, you can use RDS Proxy or run PgBouncer on a small container.
| Strategy | How it works | When to use |
|---|---|---|
| Client-side pool | Driver maintains open TCP connections in the process | Long-running Node.js servers, containers |
| External pooler | PgBouncer, RDS Proxy, or managed pooler multiplexes connections | Serverless functions, high concurrency |
| HTTP driver | Each query is a stateless HTTP request | Edge Runtime, Vercel, Neon |
| Connection proxy service | Prisma Accelerate, PlanetScale serverless | Prisma on edge, global low latency |
Prisma Accelerate is a managed connection pool and query cache that sits in front of your database. It is especially useful when you want to run Prisma on the edge or in serverless environments where the native engine is hard to manage. The connection string starts with prisma://and routes queries through Accelerate's edge network.
| 1 | # Use a pooled connection string for serverless |
| 2 | DATABASE_URL="postgres://user:pass@pooler.example.com:5432/db?pgbouncer=true" |
| 3 | |
| 4 | # With Prisma Accelerate on the edge |
| 5 | DATABASE_URL="prisma://accelerate.prisma-data.net/?api_key=..." |
Cold starts also affect database workflows. A freshly started function must establish a connection, initialize the ORM client, and sometimes run migrations. Keep initialization code lightweight, use a singleton client, and avoid running heavy queries inside the first request. Warm pools and provisioned concurrency help if your latency budget is tight.
danger
Server Actions let you call server-side functions directly from the client, typically from forms. They are the recommended way to handle mutations in the App Router. Because they run on the server, they can access the database, environment variables, and cookies, and they include CSRF protection by default.
A well-written mutation validates input, runs the write inside a transaction when needed, and returns a structured result. The UI can then use that result to display errors, trigger revalidation, or apply optimistic updates. Avoid leaking raw database errors to the client; map them to user-friendly messages.
| 1 | // app/actions/post.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { revalidatePath } from "next/cache"; |
| 5 | import { redirect } from "next/navigation"; |
| 6 | import { z } from "zod"; |
| 7 | import prisma from "@/lib/prisma"; |
| 8 | |
| 9 | const createPostSchema = z.object({ |
| 10 | title: z.string().min(1).max(200), |
| 11 | slug: z.string().regex(/^[a-z0-9-]+$/), |
| 12 | content: z.string().min(10), |
| 13 | authorId: z.string().uuid(), |
| 14 | }); |
| 15 | |
| 16 | export type CreatePostResult = |
| 17 | | { success: true; postId: string } |
| 18 | | { success: false; errors: string[] }; |
| 19 | |
| 20 | export async function createPost( |
| 21 | formData: FormData |
| 22 | ): Promise<CreatePostResult> { |
| 23 | const parsed = createPostSchema.safeParse({ |
| 24 | title: formData.get("title"), |
| 25 | slug: formData.get("slug"), |
| 26 | content: formData.get("content"), |
| 27 | authorId: formData.get("authorId"), |
| 28 | }); |
| 29 | |
| 30 | if (!parsed.success) { |
| 31 | return { |
| 32 | success: false, |
| 33 | errors: parsed.error.issues.map((i) => i.message), |
| 34 | }; |
| 35 | } |
| 36 | |
| 37 | try { |
| 38 | const post = await prisma.post.create({ |
| 39 | data: parsed.data, |
| 40 | }); |
| 41 | |
| 42 | revalidatePath("/posts"); |
| 43 | revalidatePath('/posts/' + post.slug); |
| 44 | |
| 45 | return { success: true, postId: post.id }; |
| 46 | } catch (error) { |
| 47 | return { success: false, errors: ["Unable to create post. Try a different slug."] }; |
| 48 | } |
| 49 | } |
For transactions, wrap multiple writes in a single database transaction. In Prisma, use prisma.$transaction. In Drizzle, use db.transaction. This keeps related changes atomic and consistent. If one step fails, the database rolls back all of them, preventing partial updates that corrupt state.
| 1 | // app/actions/billing.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import prisma from "@/lib/prisma"; |
| 5 | |
| 6 | export async function transferCredits( |
| 7 | fromUserId: string, |
| 8 | toUserId: string, |
| 9 | amount: number |
| 10 | ) { |
| 11 | await prisma.$transaction(async (tx) => { |
| 12 | const fromBalance = await tx.user.update({ |
| 13 | where: { id: fromUserId }, |
| 14 | data: { credits: { decrement: amount } }, |
| 15 | }); |
| 16 | |
| 17 | if (fromBalance.credits < 0) { |
| 18 | throw new Error("Insufficient credits"); |
| 19 | } |
| 20 | |
| 21 | await tx.user.update({ |
| 22 | where: { id: toUserId }, |
| 23 | data: { credits: { increment: amount } }, |
| 24 | }); |
| 25 | }); |
| 26 | } |
Optimistic updates are a UI pattern that assumes the mutation will succeed and updates the interface immediately. When the server responds, the UI reconciles the optimistic state with the actual result. The Next.js App Router supports this through useOptimistic in a client component that wraps the form. The Server Action itself remains the source of truth.
pro tip
Next.js caches data fetched during rendering. By default, fetch calls are cached unless you opt out with dynamic rendering. But database queries made through an ORM are not fetch calls, so they are not cached automatically. To cache ORM queries, you can use the unstable_cache helper or wrap the query in a fetch request with a custom cache tag.
Caching is a powerful tool, but it introduces a trade-off between freshness and performance. Cache user-specific or frequently changing data sparingly. Cache semi-static reference data, product catalogs, and published posts more aggressively. Always measure the hit rate and invalidate on relevant mutations.
| 1 | // lib/cache/posts.ts |
| 2 | import { unstable_cache } from "next/cache"; |
| 3 | import prisma from "@/lib/prisma"; |
| 4 | |
| 5 | export const getCachedPosts = unstable_cache( |
| 6 | async () => { |
| 7 | return prisma.post.findMany({ |
| 8 | where: { published: true }, |
| 9 | orderBy: { createdAt: "desc" }, |
| 10 | take: 50, |
| 11 | }); |
| 12 | }, |
| 13 | ["posts-list"], |
| 14 | { revalidate: 60, tags: ["posts"] } |
| 15 | ); |
In a Server Component, you can call the cached function directly. The first render stores the result, and subsequent renders within the revalidation window serve the cached value. When a post is created, updated, or deleted, call revalidateTag("posts") to purge the cache and refresh the data on the next request.
| 1 | // app/page.tsx |
| 2 | import { getCachedPosts } from "@/lib/cache/posts"; |
| 3 | |
| 4 | export default async function HomePage() { |
| 5 | const posts = await getCachedPosts(); |
| 6 | |
| 7 | return ( |
| 8 | <ul> |
| 9 | {posts.map((post) => ( |
| 10 | <li key={post.id}>{post.title}</li> |
| 11 | ))} |
| 12 | </ul> |
| 13 | ); |
| 14 | } |
Some teams prefer to wrap the database query in a fetch call with a custom cache tag. This works because Next.js caches fetch responses automatically. You can pass a cache key and revalidate time, but the response body must be serializable JSON. This approach is useful when you want to share a cache between server and client fetches.
warning
A healthy database layer in Next.js is built on a small set of habits: keep the client off the database, validate inputs, reuse connections, handle errors gracefully, and cache with intention. These practices compound over time and prevent the most common production failures.
1. Use a singleton database client. Whether you use Prisma, Drizzle, or a raw driver, initialize the connection once per process and reuse it.
2. Validate environment variables before the server starts. A missing DATABASE_URL should fail fast, not during the first request.
3. Never query the database from a client component. Client components cannot keep secrets safe; they should call Server Actions or route handlers.
4. Run migrations outside the request path. Apply them during build or deployment, not inside a Server Action or route handler.
5. Use transactions for multi-step mutations. Atomic writes prevent inconsistent states when a step fails.
6. Handle database errors close to the query. Map driver-specific errors to domain-level results before sending them to the client.
7. Pool connections in serverless environments. Use a managed pooler or an HTTP driver to avoid connection exhaustion.
8. Cache intentionally, not by default. Use unstable_cache or fetch tags, and invalidate on mutations.
9. Monitor query performance. Log slow queries, set alerts on connection counts, and use database indexes for common filters.
10. Test with production-like data. Small local datasets hide N+1 queries, missing indexes, and slow aggregations.
best practice