Deleting Posts in Next.js: Complete CRUD with Server Actions
Step 17 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
By the end of this step, each post detail page will have a Delete button — visible only to the post's author. Clicking it deletes the post from the database and redirects back to the posts list. This completes the CRUD cycle: Create (Step 16), Read (Steps 8 and 10), Update (future enhancement), and Delete (this step).
Goal: Add a delete Server Action, bind it to a button with a post ID argument, and enforce ownership — users can only delete their own posts.
Table of Contents
- Create the Delete Server Action
- Core Concept:
.bind()— Passing Arguments to Server Actions - Build the Delete Button Component
- Add the Delete Button to the Post Detail Page
- Understanding the Code
- Verify the Result
- The Complete CRUD Summary
- Summary & Key Takeaways
Create the Delete Server Action
Add the deletePost function to app/actions.ts (the same file where createPost lives):
'use server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
// ... (existing code from Step 16)
}
export async function deletePost(postId: number) {
const session = await auth()
if (!session?.user?.email) {
throw new Error('You must be signed in to delete a post')
}
// Find the post and verify ownership
const post = await prisma.post.findUnique({
where: { id: postId },
include: { author: true },
})
if (!post) {
throw new Error('Post not found')
}
if (post.author?.email !== session.user.email) {
throw new Error('You can only delete your own posts')
}
await prisma.post.delete({
where: { id: postId },
})
redirect('/posts')
}
What this action does
- Check authentication — must be signed in.
- Find the post — fetch it with the author to check ownership.
- Verify ownership — compare the post author's email with the session email. If they do not match, throw an error.
- Delete the post — remove it from the database.
- Redirect — send the user back to the posts list.
Core Concept: .bind() — Passing Arguments to Server Actions
In Step 16, the createPost action received FormData from a form. But deletePost needs a specific post ID — not form data. How do we pass it?
The action prop on a form expects a function with the signature (formData: FormData) => void. To pass additional arguments, we use JavaScript's .bind() method:
const deletePostWithId = deletePost.bind(null, post.id)
.bind(null, post.id) creates a new function where post.id is "pre-filled" as the first argument. When the form is submitted:
deletePost.bind(null, 42)
↓
// Creates a new function equivalent to:
(formData) => deletePost(42, formData)
So deletePost receives postId as the first argument and formData as the second (which we ignore in this case).
Why .bind() instead of an arrow function?
// ❌ This creates a new function on every render — does not work with Server Actions
<form action={() => deletePost(post.id)}>
// ✅ .bind() works with Server Actions
<form action={deletePost.bind(null, post.id)}>
Arrow functions do not work as Server Actions because they are anonymous and cannot be serialized. .bind() creates a function reference that Next.js can track and send to the server.
Build the Delete Button Component
Create app/posts/[id]/DeleteButton.tsx:
'use client'
import { useState } from 'react'
import { deletePost } from '@/app/actions'
export default function DeleteButton({ postId }: { postId: number }) {
const [confirming, setConfirming] = useState(false)
const deletePostWithId = deletePost.bind(null, postId)
if (confirming) {
return (
<div className="flex items-center gap-2">
<span className="text-sm text-red-600">Delete this post?</span>
<form action={deletePostWithId}>
<button
type="submit"
className="px-3 py-1 text-sm text-white bg-red-600 rounded hover:bg-red-700"
>
Yes, delete
</button>
</form>
<button
onClick={() => setConfirming(false)}
className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded hover:bg-gray-200"
>
Cancel
</button>
</div>
)
}
return (
<button
onClick={() => setConfirming(true)}
className="px-3 py-1 text-sm text-red-600 border border-red-300 rounded hover:bg-red-50"
>
Delete
</button>
)
}
Why a separate Client Component?
The delete button needs interactivity — a confirmation step with useState. The post detail page is a Server Component (it uses auth() and prisma). Instead of making the entire page a Client Component, we extract just the interactive button into its own file.
This is the same pattern from Step 13 — keep Server Components as the default, and only use Client Components for interactive pieces.
Add the Delete Button to the Post Detail Page
Update app/posts/[id]/page.tsx to show the delete button when the logged-in user is that post's author:
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import DeleteButton from './DeleteButton'
type Props = {
params: Promise<{ id: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params
const post = await prisma.post.findUnique({
where: { id: Number(id) },
})
return {
title: post ? `${post.title} — Superblog` : 'Post Not Found',
}
}
export default async function PostPage({ params }: Props) {
const { id } = await params
const session = await auth()
const post = await prisma.post.findUnique({
where: { id: Number(id) },
include: { author: true },
})
if (!post) {
notFound()
}
const isAuthor = session?.user?.email === post.author?.email
return (
<>
<Link
href="/posts"
className="mb-6 inline-block text-sm text-teal-600 hover:text-teal-700"
>
← Back to all posts
</Link>
<article className="rounded-lg bg-white p-8 shadow-md">
<div className="flex items-start justify-between mb-2">
<h2 className="text-3xl font-bold text-gray-900">{post.title}</h2>
{isAuthor && <DeleteButton postId={post.id} />}
</div>
<p className="mb-6 text-sm text-gray-500">
By {post.author?.name ?? 'Unknown'} ·{' '}
{new Date(post.createdAt).toLocaleDateString()}
</p>
<div className="prose max-w-none text-gray-700">
<p>{post.content}</p>
</div>
</article>
</>
)
}
What changed from Step 10
- Added
import { auth } from '@/auth'andconst session = await auth(). - Added
import DeleteButton from './DeleteButton'. - Added
const isAuthor = session?.user?.email === post.author?.email— checks if the logged-in user wrote this post. - Added
{isAuthor && <DeleteButton postId={post.id} />}— only renders the delete button if the user is the author.
Understanding the Code
Ownership check on the page
const isAuthor = session?.user?.email === post.author?.email
This compares the logged-in user's email with the post author's email. If they match, the user is the author and can see the delete button. If there is no session (not logged in) or the emails do not match, isAuthor is false and the button is hidden.
Ownership check in the action
if (post.author?.email !== session.user.email) {
throw new Error('You can only delete your own posts')
}
The same check happens again inside the Server Action. This is critical — hiding the button in the UI is not security. A malicious user could call the Server Action directly. The server-side check is the real protection.
Confirmation pattern
const [confirming, setConfirming] = useState(false)
The delete button has two states:
- Default — shows a "Delete" button
- Confirming — shows "Delete this post?" with "Yes, delete" and "Cancel" buttons
This prevents accidental deletions. The user must click twice to delete — first to show the confirmation, then to actually delete.
prisma.post.delete()
await prisma.post.delete({
where: { id: postId },
})
delete() removes a single record by its unique field. If no record matches, Prisma throws a PrismaClientKnownRequestError. In our case, we already verified the post exists in the ownership check above, so this should always succeed.
The .bind() flow
1. DeleteButton receives postId={42}
2. deletePost.bind(null, 42) creates a bound function
3. <form action={boundFunction}> — submitted on click
4. Next.js calls deletePost(42) on the server
5. Server Action verifies ownership and deletes the post
6. redirect('/posts') sends the user back to the list
Verify the Result
Make sure your dev server is running:
pnpm dev
Test deleting your own post
- Sign in and create a new post (or use one you already created).
- Navigate to the post's detail page.
- You should see a "Delete" button next to the title.
- Click "Delete" — a confirmation appears.
- Click "Yes, delete" — you should be redirected to
/posts. - The post should no longer appear in the list.
Test that you cannot delete others' posts
- Navigate to a post created by a different user (from the seed data).
- The "Delete" button should not appear — you are not the author.
Test without authentication
- Sign out.
- Navigate to any post detail page.
- The delete button should not appear (no session means
isAuthorisfalse).
The Complete CRUD Summary
You have now built a complete CRUD application:
| Operation | Step | How | Route |
|---|---|---|---|
| Create | Step 16 | Server Action (createPost) | /posts/new |
| Read (list) | Step 8 → 12 | Server Component → Client Component with pagination | /posts |
| Read (detail) | Step 10 | Server Component with dynamic route | /posts/[id] |
| Update | — | Future enhancement | — |
| Delete | Step 17 | Server Action (deletePost) | Delete button on /posts/[id] |
The tech stack
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| UI | React 19, Tailwind CSS |
| Database | PostgreSQL via Prisma ORM |
| Authentication | NextAuth.js v5 (Auth.js) |
| Data mutations | Server Actions |
| API | Route Handlers (for external access and pagination) |
| Route protection | Middleware + server-side auth checks |
Summary & Key Takeaways
| Concept | What it means |
|---|---|
prisma.post.delete() | Removes a single record by its unique field |
.bind(null, arg) | Pre-fills an argument for a Server Action — required for passing data beyond FormData |
| Ownership check | Compare session.user.email with post.author.email — enforce on both UI and server |
| Confirmation pattern | Two-click delete with useState — prevents accidental deletions |
| CRUD complete | Create, Read, Delete implemented — Update is a future enhancement |
Congratulations!
You have built a full-stack CRUD application from scratch with Next.js 16, React 19, Prisma, and NextAuth.js. You now understand:
- File-based routing and dynamic routes
- Server Components and Client Components
- Database queries with Prisma
- API routes and Server Actions
- Authentication with NextAuth.js
- Middleware and route protection
- Session management on both server and client
From here, you can extend the app with features like post editing, image uploads, comments, tags, or deploy it to production with Vercel.