Next.js Database Integration

Connect a database to Next.js, query it in Server Components, and write with Server Actions.

TL;DR

  1. Create one shared client in lib/db.ts and import it everywhere.
  2. Query the database with await directly inside Server Components.
  3. Write data in a 'use server' function, then refresh the page.

Connecting

    DATABASE_URL

    Keep the connection string in an environment file, never in your code.

    # .env.local
    DATABASE_URL="postgres://user:pass@host:5432/app"
    lib/db.ts

    Create 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!);
    globalThis

    Reuse 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 Page

    Run 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 Parameters

    Values 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 Rows

    Pass 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 Layer

    Keep 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

    INSERT

    Insert 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 DELETE

    Change 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

    Prisma

    Run 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' },
    });
    Drizzle

    Build the query with Drizzle's typed, SQL-like functions.

    const rows = await db
      .select()
      .from(posts)
      .where(eq(posts.published, true));
    Cached Query

    Cache 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

  1. Add import 'server-only' to your database module, so the build fails if client code ever imports it.
  2. Use a pooled connection string on serverless hosts, because each function instance opens its own DATABASE_URL connection.

Warnings

  1. Building SQL with string concatenation allows injection attacks, so always pass values through the sql tagged template or your ORM.
  2. Creating a new client on every request exhausts connections, so reuse one instance stored on globalThis during development.

In Practice

FAQ