Next.js Database Integration
Connect a database to Next.js, query it in Server Components, and write with Server Actions.
TL;DR
- Create one shared client in
lib/db.tsand import it everywhere. - Query the database with
awaitdirectly inside Server Components. - Write data in a
'use server'function, then refresh the page.
Connecting
DATABASE_URLKeep the connection string in an environment file, never in your code.
# .env.local
DATABASE_URL="postgres://user:pass@host:5432/app"lib/db.tsCreate the client once in a server-only module and export it.
// lib/db.ts
import 'server-only';
import postgres from 'postgres';
export const sql = postgres(process.env.DATABASE_URL!);globalThisReuse one client across hot reloads so development does not leak connections.
const g = globalThis as { sql?: postgres.Sql };
export const sql =
g.sql ?? postgres(process.env.DATABASE_URL!);
if (process.env.NODE_ENV !== 'production') {
g.sql = sql;
}Reading Data
Query in a PageRun a query with await inside a Server Component and render the rows.
import { sql } from '@/lib/db';
export default async function Page() {
const posts = await sql`
SELECT id, title FROM posts ORDER BY id DESC
`;
return <p>{posts.length} posts</p>;
}Safe ParametersValues in the template are sent as parameters, which prevents SQL injection.
const rows = await sql`
SELECT * FROM posts WHERE slug = ${slug}
`;
const post = rows[0];Typed RowsPass a type to the query so the result rows are fully typed.
type Post = { id: number; title: string };
const posts = await sql<Post[]>`
SELECT id, title FROM posts
`;Data LayerKeep queries in one module so pages call functions instead of writing SQL.
// lib/posts.ts
export async function getPost(slug: string) {
const rows = await sql<Post[]>`
SELECT * FROM posts WHERE slug = ${slug}
`;
return rows[0];
}Writing Data
INSERTInsert a row from a Server Function, then refresh the page that lists it.
'use server';
import { revalidatePath } from 'next/cache';
export async function addPost(form: FormData) {
const title = String(form.get('title') ?? '');
await sql`
INSERT INTO posts (title) VALUES (${title})
`;
revalidatePath('/posts');
}UPDATE and DELETEChange or remove a row by id, always passing values as parameters.
await sql`
UPDATE posts SET title = ${title} WHERE id = ${id}
`;
await sql`DELETE FROM posts WHERE id = ${id}`;sql.begin()Group related writes in a transaction so they all succeed or all fail.
await sql.begin(async (tx) => {
await tx`
UPDATE accounts SET balance = balance - 50
WHERE id = ${from}
`;
await tx`
UPDATE accounts SET balance = balance + 50
WHERE id = ${to}
`;
});ORMs and Caching
PrismaRun the same kind of read with Prisma's typed client instead of SQL.
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
});DrizzleBuild the query with Drizzle's typed, SQL-like functions.
const rows = await db
.select()
.from(posts)
.where(eq(posts.published, true));Cached QueryCache a query and tag it, so you can refresh it after a write.
import { cacheTag } from 'next/cache';
export async function getPosts() {
'use cache';
cacheTag('posts');
return sql`SELECT * FROM posts`;
}Tips
- Add
import 'server-only'to your database module, so the build fails if client code ever imports it. - Use a pooled connection string on serverless hosts, because each function instance opens its own
DATABASE_URLconnection.
Warnings
- Building SQL with string concatenation allows injection attacks, so always pass values through the
sqltagged template or your ORM. - Creating a new client on every request exhausts connections, so reuse one instance stored on
globalThisduring development.
In Practice
Show posts from the database and add new ones with a form, all in one route.
- The Server Function writes the row and then revalidates the path.
- The value goes through the sql template, so it is sent as a safe parameter.
- The page queries the database directly, because it runs on the server.
- The form posts to the function, so no API route or client fetch is needed.
// app/posts/page.tsx
import { revalidatePath } from 'next/cache';
import { sql } from '@/lib/db';
async function addPost(form: FormData) {
'use server';
const title = String(form.get('title') ?? '').trim();
if (!title) return;
await sql`INSERT INTO posts (title) VALUES (${title})`;
revalidatePath('/posts');
}
export default async function Page() {
const posts = await sql`SELECT id, title FROM posts`;
return (
<main>
<form action={addPost}>
<input name="title" />
<button type="submit">Add</button>
</form>
{posts.map((p) => <p key={p.id}>{p.title}</p>)}
</main>
);
}FAQ
Store the connection string in .env.local as DATABASE_URL. Create a client once in a file such as lib/db.ts, and import it into your Server Components and Server Functions.
Yes. Server Components run only on the server, so they can run queries with await. You do not need an API route between the page and the database.
Both work well. Each gives you typed queries and migrations. The patterns in this sheet stay the same: one shared client, reads in Server Components, and writes in Server Functions.
Hot reload re-runs your module and opens a new client each time. Store the client on globalThis and reuse it, so reloads share one connection pool.