|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/databases
$cat docs/databases-&-orms-in-next.js.md
updated Last weekยท35 min readยทpublished

Databases & ORMs in Next.js

โ—†Next.jsโ—†Databaseโ—†ORMโ—†Intermediate to Advanced๐ŸŽฏFree Tools
Introduction

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.

Server Component Data Access

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.

page.tsx
TSX
1// app/page.tsx
2import prisma from "@/lib/prisma";
3
4export 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

Server Components cannot use hooks, event handlers, or browser APIs. If a component needs user interactions, split it into a client component that receives already-fetched data as props, and keep the data fetching in a Server Component or Server Action.
LocationWhen to fetch hereTrade-off
Server ComponentStatic or dynamic page data, initial renderZero client bundle impact, easy caching
Route HandlerAPI consumers, client polling, external systemsExtra HTTP hop, explicit request/response handling
Server ActionForm submissions, mutations, optimistic UICSRF protected, but runs outside render cycle
Client ComponentBrowser-only interactivity with cached dataCannot access the database directly
Prisma Setup

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.

prisma/schema.prisma
PRISMA
1// prisma/schema.prisma
2
3generator client {
4 provider = "prisma-client-js"
5}
6
7datasource db {
8 provider = "postgresql"
9 url = env("DATABASE_URL")
10}
11
12model 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
22model 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.

lib/prisma.ts
TSX
1// lib/prisma.ts
2import { PrismaClient } from "@prisma/client";
3
4const globalForPrisma = globalThis as unknown as {
5 prisma: PrismaClient | undefined;
6};
7
8export const prisma = globalForPrisma.prisma ?? new PrismaClient();
9
10if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
11
12export 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.

page.tsx
TSX
1// app/posts/[slug]/page.tsx
2import prisma from "@/lib/prisma";
3import { notFound } from "next/navigation";
4
5export 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

Prisma Client is not compatible with the Edge Runtime by default because it relies on a query engine that runs as a native binary. If you deploy to the Edge Runtime, use Prisma Accelerate or the Prisma Driver Adapter for HTTP-based drivers such as Neon or PlanetScale.

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.

shell
Bash
1# Generate the Prisma client after schema changes
2npx prisma generate
3
4# Create a migration from schema changes
5npx prisma migrate dev --name add_post_slug
6
7# Deploy pending migrations in production
8npx prisma migrate deploy
Drizzle Setup

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.

lib/db/schema.ts
TSX
1// lib/db/schema.ts
2import { pgTable, uuid, varchar, boolean, text, timestamp } from "drizzle-orm/pg-core";
3import { relations } from "drizzle-orm";
4
5export 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
12export 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
25export const usersRelations = relations(users, ({ many }) => ({
26 posts: many(posts),
27}));
28
29export 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.

lib/db/index.ts
TSX
1// lib/db/index.ts
2import { drizzle } from "drizzle-orm/neon-http";
3import { neon } from "@neondatabase/serverless";
4import * as schema from "./schema";
5
6const sql = neon(process.env.DATABASE_URL!);
7export const db = drizzle(sql, { schema });
8
9export 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.

page.tsx
TSX
1// app/posts/page.tsx
2import db from "@/lib/db";
3
4export 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.

shell
Bash
1# Generate migrations from schema changes
2npx drizzle-kit generate
3
4# Apply migrations in development or production
5npx drizzle-kit migrate
6
7# Push changes directly during early prototyping
8npx drizzle-kit push
โ„น

info

Drizzle works well with both SQL-style and relational queries. If you prefer explicit joins and aggregates, use the db.select() API. If you prefer Prisma-like relation loading, use the db.query API.
TypeORM & Other ORMs

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.

ToolStyleBest forServerless notes
PrismaSchema-first ORMRapid development, rich migrationsUse Accelerate or driver adapters on the edge
DrizzleType-safe SQLPerformance, edge deployments, SQL loversHTTP drivers are edge-friendly
TypeORMDecorator-driven ORMExisting TypeORM codebases, complex domainsConnection pooling is required
MikroORMUnit of work ORMRich domain models, identity mapCareful with lazy loading in serverless
KyselyType-safe SQL builderExplicit SQL, existing migrationsThin layer, no hidden connection pooling
Raw driverDirect SQLMaximum control, minimal dependenciesYou 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

Avoid wrapping every raw query in a generic helper that loses type safety. If you write raw SQL, use a tool that infers types from your schema, such as Kysely with generated types, or Zod schemas for query results.
Edge-Compatible Drivers

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.

DatabaseProtocolNext.js integration
NeonHTTP + WebSocket@neondatabase/serverless, drizzle-orm/neon-http
PlanetScaleHTTP over MySQL@planetscale/database, drizzle-orm/planetscale-serverless
TursoHTTP@libsql/client, drizzle-orm/libsql
Vercel PostgresHTTP@vercel/postgres, kysely-vercel-postgres
SupabaseREST / 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.

route.ts
TSX
1// app/api/edge-posts/route.ts
2import { NextResponse } from "next/server";
3import { neon } from "@neondatabase/serverless";
4
5export const runtime = "edge";
6
7export 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

Do not mix TCP drivers with the Edge Runtime. A library that tries to open a socket at the edge will fail at runtime. Always confirm the runtime compatibility of your driver before deploying an edge function.
Connection Pooling & Serverless

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.

StrategyHow it worksWhen to use
Client-side poolDriver maintains open TCP connections in the processLong-running Node.js servers, containers
External poolerPgBouncer, RDS Proxy, or managed pooler multiplexes connectionsServerless functions, high concurrency
HTTP driverEach query is a stateless HTTP requestEdge Runtime, Vercel, Neon
Connection proxy servicePrisma Accelerate, PlanetScale serverlessPrisma 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.

.env.local
Bash
1# Use a pooled connection string for serverless
2DATABASE_URL="postgres://user:pass@pooler.example.com:5432/db?pgbouncer=true"
3
4# With Prisma Accelerate on the edge
5DATABASE_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

Never open an unbounded number of connections. If you are using a traditional TCP driver in a serverless environment, always connect through a pooler and set a conservative connection limit. Database connection exhaustion is a common cause of production outages.
Server Actions & Mutations

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.

post.ts
TSX
1// app/actions/post.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5import { redirect } from "next/navigation";
6import { z } from "zod";
7import prisma from "@/lib/prisma";
8
9const 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
16export type CreatePostResult =
17 | { success: true; postId: string }
18 | { success: false; errors: string[] };
19
20export 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.

billing.ts
TSX
1// app/actions/billing.ts
2"use server";
3
4import prisma from "@/lib/prisma";
5
6export 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

Return structured results from Server Actions instead of throwing raw errors. A result object with typed success and error fields is easier to test, serialize, and display than an exception thrown across the network boundary.
Caching Database Queries

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.

posts.ts
TSX
1// lib/cache/posts.ts
2import { unstable_cache } from "next/cache";
3import prisma from "@/lib/prisma";
4
5export 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.

page.tsx
TSX
1// app/page.tsx
2import { getCachedPosts } from "@/lib/cache/posts";
3
4export 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

Do not cache data that is private, user-specific, or legally sensitive without setting proper cache headers and tags. Cache invalidation mistakes are a common source of data leaks and stale content bugs.
Best Practices

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

Treat database access as a privileged operation. The browser should never hold connection credentials, and every query should be authored, validated, and logged on the server. This is the single most important rule for database security in Next.js.
$Blueprint โ€” Engineering DocumentationยทSection ID: NEXT-DBยทRevision: 1.0