LearnAI ToolsCareerPractice BuildsPlayContact
Next.jsIntermediate~2.5 hours

Full-Stack CRUD App

Build a task manager backed by Prisma, with Server Actions for every mutation.

Server ActionsPrismaRevalidation

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.

What You'll Build
  • 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 client
lib/
prisma.ts // Shared PrismaClient singleton, imported everywhere the database is queried
app/
actions.ts // 'use server' — createTask, toggleTask, updateTask, deleteTask
page.tsx // Server Component: queries Prisma directly, renders the task list + forms
components/
EditableTaskTitle.tsx // 'use client' — the one piece of this project that needs local UI state

Step 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.prisma
// prisma/schema.prisma
generator 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.ts
// lib/prisma.ts
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') {
// 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
// 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('/');
}
Why Not Just Refetch on the Client?

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.tsx
// app/page.tsx
import { 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
// 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.

prisma/schema.prisma
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())
}
lib/prisma.ts
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;
}
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;
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('/');
}
components/EditableTaskTitle.tsx
'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>
);
}
app/page.tsx
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

A Full Mutation Cycle

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.