Next.js Server Actions with Prisma and PostgreSQL
Next.js Server Actions talking straight to a shared Prisma client and PostgreSQL, so task CRUD works with no API routes and no client-side fetch calls.
This article is published in English.
Sources: Next.js Server Actions + Prisma + PostgreSQL: The Integration Pattern You Actually Need
Guides often walk through Next.js, Prisma, and PostgreSQL as separate installs: scaffold the app, generate a client, point at a database, run a migration. The missing piece is how those three layers fit together as one integration pattern—and why that pattern removes so much of the usual full-stack ceremony.
The division of labor is clear. Server Actions move work across the server boundary. Prisma issues type-safe queries. PostgreSQL stores the rows. Each layer owns one concern. Combined, they drop a whole middle tier: no bespoke API routes, no hand-written fetch cycles, and no extra serialization path between a React component and the database.
A small task manager is enough to show the pattern end to end—from schema to a working UI—without introducing route handlers for CRUD.
Why this stack
Server Actions, Prisma, and PostgreSQL reinforce each other at every layer of a full-stack Next.js app.
Actions execute on the server, so they can import the Prisma client directly without exposing database credentials to the browser. Prisma supplies a typed query surface, so values written to PostgreSQL and values read back stay aligned across the TypeScript graph. PostgreSQL is durable, widely supported, and works cleanly with Prisma’s migration workflow.
The payoff is a short path from a button click to a persisted row—usually with less glue code than other approaches in the Next.js ecosystem.
Project setup
Begin with a new Next.js app and add Prisma:
npx create-next-app@latest my-app --typescript
cd my-app
npm install prisma @prisma/client
npx prisma init
The prisma init command creates a prisma/ folder with a schema.prisma file and a .env file. Open .env and add your PostgreSQL connection string:
```DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/mydb"
3. Define Your Schema
Open prisma/schema.prisma and define your data model. We will keep it simple — a Task model:
```generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Task {
id String @id @default(cuid())
title String
completed Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Run the migration to create the table:
```npx prisma migrate dev --name init
This creates the table and generates the Prisma Client. You are ready to query.
The integration pattern
This is the core idea. A Server Action imports the Prisma client and talks to the database itself—no API route, no client fetch, and no extra serialization layer between the UI and persistence. First, export a shared Prisma instance that survives hot reload in development:
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production')
globalForPrisma.prisma = prisma;
Then define the Server Actions module:
// app/tasks/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
export async function getTasks() {
return prisma.task.findMany({ orderBy: { createdAt: 'desc' } });
}
5. Wiring It to Your UI
Now use the Server Actions in your page and components:
```// app/tasks/page.tsx
import { getTasks } from './actions';
import { TaskForm } from './TaskForm';
import { TaskList } from './TaskList';
export default async function TasksPage() {
const tasks = await getTasks();
return (
<main>
<h1>My Tasks</h1>
<TaskForm />
<TaskList tasks={tasks} />
</main>
);
}
export async function createTask(formData: FormData) {
const title = formData.get('title') as string;
if (!title?.trim()) return { error: 'Title is required' };
await prisma.task.create({ data: { title } });
revalidatePath('/tasks');
}
export async function toggleTask(id: string, completed: boolean) {
await prisma.task.update({ where: { id }, data: { completed: !completed } });
revalidatePath('/tasks');
}
export async function deleteTask(id: string) {
await prisma.task.delete({ where: { id } });
revalidatePath('/tasks');
}
Wiring it to the UI
Call those actions from the page. The server component can invoke getTasks() directly—no useEffect, no manual loading flag, and no client-side fetch:
// app/tasks/page.tsx
import { getTasks } from './actions';
import { TaskForm } from './TaskForm';
import { TaskList } from './TaskList';
export default async function TasksPage() {
const tasks = await getTasks();
return (
<main>
<h1>My Tasks</h1>
<TaskForm />
<TaskList tasks={tasks} />
</main>
);
}
Wrapping up
Server Actions plus Prisma plus PostgreSQL give you a full-stack data path with almost no boilerplate. A component calls a function, that function writes or reads through Prisma, PostgreSQL holds the data, and revalidatePath refreshes the UI—typically in a few dozen lines rather than a separate API layer.