Deleting Posts in Next.js: Complete CRUD with Server Actions

Step 17 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

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

  1. Create the Delete Server Action
  2. Core Concept: .bind() — Passing Arguments to Server Actions
  3. Build the Delete Button Component
  4. Add the Delete Button to the Post Detail Page
  5. Understanding the Code
  6. Verify the Result
  7. The Complete CRUD Summary
  8. Summary & Key Takeaways

Create the Delete Server Action

↑ Index

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

  1. Check authentication — must be signed in.
  2. Find the post — fetch it with the author to check ownership.
  3. Verify ownership — compare the post author's email with the session email. If they do not match, throw an error.
  4. Delete the post — remove it from the database.
  5. Redirect — send the user back to the posts list.

Core Concept: .bind() — Passing Arguments to Server Actions

↑ Index

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

↑ Index

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

↑ Index

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"
      >
        &larr; 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'} &middot;{' '}
          {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

  1. Added import { auth } from '@/auth' and const session = await auth().
  2. Added import DeleteButton from './DeleteButton'.
  3. Added const isAuthor = session?.user?.email === post.author?.email — checks if the logged-in user wrote this post.
  4. Added {isAuthor && <DeleteButton postId={post.id} />} — only renders the delete button if the user is the author.

Understanding the Code

↑ Index

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:

  1. Default — shows a "Delete" button
  2. 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

↑ Index

Make sure your dev server is running:

pnpm dev

Test deleting your own post

  1. Sign in and create a new post (or use one you already created).
  2. Navigate to the post's detail page.
  3. You should see a "Delete" button next to the title.
  4. Click "Delete" — a confirmation appears.
  5. Click "Yes, delete" — you should be redirected to /posts.
  6. The post should no longer appear in the list.

Test that you cannot delete others' posts

  1. Navigate to a post created by a different user (from the seed data).
  2. The "Delete" button should not appear — you are not the author.

Test without authentication

  1. Sign out.
  2. Navigate to any post detail page.
  3. The delete button should not appear (no session means isAuthor is false).

The Complete CRUD Summary

↑ Index

You have now built a complete CRUD application:

OperationStepHowRoute
CreateStep 16Server Action (createPost)/posts/new
Read (list)Step 8 → 12Server Component → Client Component with pagination/posts
Read (detail)Step 10Server Component with dynamic route/posts/[id]
UpdateFuture enhancement
DeleteStep 17Server Action (deletePost)Delete button on /posts/[id]

The tech stack

LayerTechnology
FrameworkNext.js 16 (App Router)
UIReact 19, Tailwind CSS
DatabasePostgreSQL via Prisma ORM
AuthenticationNextAuth.js v5 (Auth.js)
Data mutationsServer Actions
APIRoute Handlers (for external access and pagination)
Route protectionMiddleware + server-side auth checks

Summary & Key Takeaways

↑ Index

ConceptWhat 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 checkCompare session.user.email with post.author.email — enforce on both UI and server
Confirmation patternTwo-click delete with useState — prevents accidental deletions
CRUD completeCreate, 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.