Overview
A task manager is the project where Next.js stops being just a frontend framework and becomes a genuinely full-stack one: the same file that renders the task list also queries the database directly, and the buttons that create, edit, complete, and delete a task call server-side functions with no separate API layer written by hand in between. Prisma is the ORM doing the actual database talking — a type-safe query builder generated from a schema file, so `prisma.task.findMany()` comes back typed as an array of real `Task` objects, not `any`.
By the end of this tutorial you will have a `Task` model defined in `prisma/schema.prisma`, a shared Prisma Client instance in `lib/prisma.ts`, Server Actions in `app/actions.ts` covering every mutation a task can undergo, and a Server Component page that queries Prisma directly and renders inline forms bound to those actions — no `useState` for the task list itself, no client-side fetch, and no API route required for any of it.
- A `Task` model in `prisma/schema.prisma` with `title`, `completed`, and `createdAt` fields.
- A `lib/prisma.ts` singleton that reuses one `PrismaClient` across hot reloads in development.
- `createTask`, `toggleTask`, `updateTask`, and `deleteTask` Server Actions, each calling `revalidatePath` after writing to the database.
- A Server Component `app/page.tsx` that queries Prisma directly and lists every task with inline forms.
- `.bind()` used to pass a task's id into a Server Action from an inline `<form action={...}>`.
- A small Client Component for inline title editing, calling a Server Action from inside a client event.
Prerequisites
- Server Actions and the `'use server'` directive, covered in this course's Authenticated Dashboard practice build.
- Server vs Client Components — this project deliberately mixes both, so knowing when each is required matters here more than in earlier projects.
- Basic SQL/relational data modeling — a table with a primary key and typed columns.
- Prisma itself is new here and introduced from scratch — you do not need to know it already, though prior exposure to any ORM (or this course's MySQL prerequisite) will make the schema feel familiar.
- Solid React fundamentals — `useState`, event handlers, and conditional rendering, used in the Step 5 client component.
Project Structure
`prisma/schema.prisma` defines the data model and is used by the `prisma` CLI to generate a typed client and run migrations — it is not imported by application code directly. `lib/prisma.ts` is what application code actually imports: one shared `PrismaClient` instance. `app/actions.ts` holds every mutation as its own exported Server Action, and `app/page.tsx` is the single Server Component that reads the task list and renders the forms that call those actions.
prisma/ schema.prisma // Task model definition; used by the Prisma CLI to generate the clientlib/ prisma.ts // Shared PrismaClient singleton, imported everywhere the database is queriedapp/ actions.ts // 'use server' — createTask, toggleTask, updateTask, deleteTask page.tsx // Server Component: queries Prisma directly, renders the task list + formscomponents/ EditableTaskTitle.tsx // 'use client' — the one piece of this project that needs local UI stateStep 1: Define the Prisma Schema
The `datasource` block's `provider` line is the only thing that changes between databases — swapping `"postgresql"` for `"sqlite"` or `"mysql"` here never requires touching any application code, since every query still goes through the same generated Prisma Client either way. `@default(cuid())` generates a collision-resistant unique string id automatically, which is preferable to a plain auto-increment integer for anything that might ever be exposed in a URL, since sequential ids let a visitor guess `/tasks/2` right after seeing `/tasks/1`.
// prisma/schema.prismagenerator client { provider = "prisma-client-js"}
datasource db { provider = "postgresql" // swap for "sqlite" or "mysql" without touching any application code url = env("DATABASE_URL")}
model Task { id String @id @default(cuid()) // cuid() avoids the sequential-guessing problem of an auto-increment int id title String completed Boolean @default(false) createdAt DateTime @default(now())}Step 2: Create a Prisma Client Singleton
Next.js hot-reloads server code on every save in development, and without this guard, each reload would construct a brand-new `PrismaClient` — and each one opens its own database connection pool, until the database eventually refuses any more connections. Stashing the client on Node's `global` object survives a hot reload (globals persist across module re-evaluation, unlike a plain module-level `const`), so development mode reuses the same client instead of leaking a new one on every save.
// lib/prisma.tsimport { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') { // Only needed in development — a production server process starts once // and never hot-reloads, so there's nothing to guard against there. globalForPrisma.prisma = prisma;}Step 3: Write Server Actions for Every Mutation
Every action below ends with the same call: `revalidatePath('/')`. Without it, a mutation would still succeed in the database, but the cached HTML for `/` would keep showing the pre-mutation state until something else happened to trigger a refresh — `revalidatePath` is what tells Next.js "the data behind this route just changed, throw away the cache and rebuild it on the next request."
// app/actions.ts'use server';
import { revalidatePath } from 'next/cache';import { prisma } from '@/lib/prisma';
export async function createTask(formData: FormData) { const title = String(formData.get('title') ?? '').trim(); if (title === '') return; // silently ignore an empty submission rather than creating a blank task
await prisma.task.create({ data: { title } }); revalidatePath('/');}
export async function toggleTask(id: string, completed: boolean) { await prisma.task.update({ where: { id }, data: { completed: !completed }, // flips whatever the CURRENT value was, passed in by the caller }); revalidatePath('/');}
export async function updateTask(id: string, formData: FormData) { const title = String(formData.get('title') ?? '').trim(); if (title === '') return;
await prisma.task.update({ where: { id }, data: { title } }); revalidatePath('/');}
export async function deleteTask(id: string) { await prisma.task.delete({ where: { id } }); revalidatePath('/');}A Client Component could call these same functions and manage a local task array with `useState`, refetching after every change. `revalidatePath` skips all of that: the Server Component in Step 4 always reads straight from the database on render, so there is no client-side cache to keep in sync in the first place — only the server's cached HTML output, which `revalidatePath` invalidates directly.
Step 4: Build the Task List Page
This Server Component queries Prisma directly with no `fetch()` and no API route in between it and the database — reasonable specifically because it only ever runs on the server, right next to the database itself. `.bind(null, task.id, task.completed)` pre-fills a Server Action's leading parameters before it is ever handed to `<form action={...}>`, which only ever calls the resulting function with the `FormData` it collects — this is how each row's toggle and delete buttons know which task they belong to without a hidden `<input>` for the id.
// app/page.tsximport { prisma } from '@/lib/prisma';import { createTask, toggleTask, deleteTask } from './actions';
export default async function TaskListPage() { const tasks = await prisma.task.findMany({ orderBy: { createdAt: 'desc' } });
return ( <div className="task-manager"> <h1>Tasks</h1>
<form action={createTask} className="new-task-form"> <input name="title" type="text" placeholder="Add a task..." required /> <button type="submit">Add</button> </form>
<ul className="task-list"> {tasks.map((task) => ( <li key={task.id} className={task.completed ? 'completed' : ''}> {/* bind() carries this task's id and current completed state along with the action itself, so the <form> only ever needs to submit the toggle button click, nothing else */} <form action={toggleTask.bind(null, task.id, task.completed)}> <button type="submit" className="toggle-btn"> {task.completed ? '\u2611' : '\u2610'} </button> </form>
<span className="task-title">{task.title}</span>
<form action={deleteTask.bind(null, task.id)}> <button type="submit" className="delete-btn">Delete</button> </form> </li> ))} </ul> </div> );}Step 5: Add Inline Editing with a Client Component
Toggling between "showing plain text" and "showing an editable input" is local, ephemeral UI state that has nothing to do with the database — exactly the kind of state a Server Component cannot hold, since it never runs again after the page is sent to the browser. That is what `'use client'` is for here: `EditableTaskTitle` needs `useState` and a click handler, neither of which a Server Component can have. Note that the imported `updateTask` Server Action is still called directly from client-side code — Server Actions are callable from a Client Component too, not only from a plain `<form action={...}>`; React sends the call over the network to the server automatically.
// components/EditableTaskTitle.tsx'use client'; // needs local isEditing state and a DOM click handler — neither is possible in a Server Component
import { useState } from 'react';import { updateTask } from '@/app/actions';
export default function EditableTaskTitle({ id, title }: { id: string; title: string }) { const [isEditing, setIsEditing] = useState(false);
if (!isEditing) { return ( <span className="task-title" onClick={() => setIsEditing(true)}> {title} </span> ); }
return ( <form action={async (formData: FormData) => { // Calling a Server Action directly from client code (not just from a // <form>'s action prop) — React ships this call to the server and // awaits the result like any other async function. await updateTask(id, formData); setIsEditing(false); }} > <input name="title" type="text" defaultValue={title} autoFocus /> <button type="submit">Save</button> </form> );}Back in `app/page.tsx`, the plain `<span className="task-title">{task.title}</span>` from Step 4 is replaced with this component, passed the task's id and current title as props — `<EditableTaskTitle id={task.id} title={task.title} />` — everything else in the file stays exactly as it was.
Complete Code
Here is the complete project — the schema, the client singleton, the Server Actions, and both components, fully assembled from the steps above.
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())}import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') { globalForPrisma.prisma = prisma;}'use server';
import { revalidatePath } from 'next/cache';import { prisma } from '@/lib/prisma';
export async function createTask(formData: FormData) { const title = String(formData.get('title') ?? '').trim(); if (title === '') return;
await prisma.task.create({ data: { title } }); revalidatePath('/');}
export async function toggleTask(id: string, completed: boolean) { await prisma.task.update({ where: { id }, data: { completed: !completed } }); revalidatePath('/');}
export async function updateTask(id: string, formData: FormData) { const title = String(formData.get('title') ?? '').trim(); if (title === '') return;
await prisma.task.update({ where: { id }, data: { title } }); revalidatePath('/');}
export async function deleteTask(id: string) { await prisma.task.delete({ where: { id } }); revalidatePath('/');}'use client';
import { useState } from 'react';import { updateTask } from '@/app/actions';
export default function EditableTaskTitle({ id, title }: { id: string; title: string }) { const [isEditing, setIsEditing] = useState(false);
if (!isEditing) { return ( <span className="task-title" onClick={() => setIsEditing(true)}> {title} </span> ); }
return ( <form action={async (formData: FormData) => { await updateTask(id, formData); setIsEditing(false); }} > <input name="title" type="text" defaultValue={title} autoFocus /> <button type="submit">Save</button> </form> );}import { prisma } from '@/lib/prisma';import { createTask, toggleTask, deleteTask } from './actions';import EditableTaskTitle from '@/components/EditableTaskTitle';
export default async function TaskListPage() { const tasks = await prisma.task.findMany({ orderBy: { createdAt: 'desc' } });
return ( <div className="task-manager"> <h1>Tasks</h1>
<form action={createTask} className="new-task-form"> <input name="title" type="text" placeholder="Add a task..." required /> <button type="submit">Add</button> </form>
<ul className="task-list"> {tasks.map((task) => ( <li key={task.id} className={task.completed ? 'completed' : ''}> <form action={toggleTask.bind(null, task.id, task.completed)}> <button type="submit" className="toggle-btn"> {task.completed ? '\u2611' : '\u2610'} </button> </form>
<EditableTaskTitle id={task.id} title={task.title} />
<form action={deleteTask.bind(null, task.id)}> <button type="submit" className="delete-btn">Delete</button> </form> </li> ))} </ul> </div> );}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a `dueDate` and `priority` field to the `Task` model and sort/filter the list by them.
- Scope tasks per user by adding a `userId` field and combining this project with the Authenticated Dashboard practice build's session cookie.
- Add `useOptimistic` in a client wrapper so a toggle or delete appears instant in the UI, before the Server Action's response actually returns.
- Add a "Clear Completed" Server Action that deletes every task where `completed: true` in one `prisma.task.deleteMany()` call.
- Move the create/edit forms into a modal built as a Client Component, keeping the list itself a Server Component.
Summary
You built a task manager where a Server Component queries Prisma directly with no API layer in between, and every mutation — create, toggle, update, delete — runs as its own Server Action that calls `revalidatePath` so the next render always reflects the database's current state. That pattern, a typed ORM plus Server Actions plus targeted revalidation, is what "full-stack Next.js" means in practice, and it scales from a task manager like this one up to far larger applications without ever needing a hand-written REST or GraphQL layer in between.