Updating Posts in Next.js: Edit Form with Server Actions & Prisma

Step 19 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 an Edit button — visible only to the post's author. Clicking it opens an edit page with a pre-filled form. Submitting the form updates the post in the database and redirects back to the post detail page. This completes the full CRUD cycle: Create (Step 16), Read (Steps 8 and 10), Update (this step), and Delete (Step 17).

Goal: Add an updatePost Server Action, create an edit page at /posts/[id]/edit, pre-fill the form with existing post data, enforce ownership, and use prisma.post.update().


Table of Contents

  1. Create the Update Server Action
  2. Understanding the Update Action
  3. Build the Edit Page
  4. Understanding the Edit Page
  5. Add the Edit Button to the Post Detail Page
  6. Verify the Result
  7. The Complete CRUD Summary
  8. File Structure After This Step
  9. Summary & Key Takeaways

Create the Update Server Action

↑ Index

Open app/actions.ts and add the updatePost function alongside the existing createPost and deletePost:

export async function updatePost(postId: number, formData: FormData) {
  const session = await auth();

  if (!session?.user?.email) {
    throw new Error("You must be signed in to edit 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 edit your own posts");
  }

  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  if (!title || !content) {
    throw new Error("Title and content are required");
  }

  await prisma.post.update({
    where: { id: postId },
    data: { title, content },
  });

  redirect(`/posts/${postId}`);
}

No migration needed. The Post model already has an updatedAt field with @updatedAt — Prisma updates this automatically whenever you call prisma.post.update(). You do not need to change the schema or run a migration.


Understanding the Update Action

↑ Index

Let's compare updatePost with the existing createPost and deletePost:

AspectcreatePostdeletePostupdatePost
ArgumentsformData: FormDatapostId: numberpostId: number, formData: FormData
Auth checksession?.user?.emailsession?.user?.emailsession?.user?.email
Ownership checkNot needed (creating new)Yes — post.author?.emailYes — post.author?.email
Prisma methodprisma.post.create()prisma.post.delete()prisma.post.update()
Redirect/posts/${post.id}/posts/posts/${postId}

Notice that updatePost takes two arguments: the postId to identify which post to update, and formData containing the new title and content. This is the same pattern as deletePost (which takes postId) combined with createPost (which takes formData).

How .bind() works with two arguments

In the delete step, we used .bind(null, postId) to pre-fill the first argument of a Server Action. The same pattern works here:

const updatePostWithId = updatePost.bind(null, post.id);
// Now updatePostWithId(formData) calls updatePost(post.id, formData)

When React calls the form's action, it passes FormData as the last argument. The .bind() fills in the first argument (postId). So the function receives both.


Build the Edit Page

↑ Index

Create a new file at app/posts/[id]/edit/page.tsx:

import { updatePost } from "@/app/actions";
import { auth } from "@/auth";
import prisma from "@/lib/prisma";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";

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
      ? `Edit: ${post.title} — NextJs-FullStack-App-Blog-APP`
      : "Post Not Found",
  };
}

export default async function EditPostPage({ params }: Props) {
  const { id } = await params;
  const session = await auth();

  if (!session) {
    redirect("/login");
  }

  const post = await prisma.post.findUnique({
    where: { id: Number(id) },
    include: { author: true },
  });

  if (!post) {
    notFound();
  }

  // Only the author can edit
  if (post.author?.email !== session.user?.email) {
    redirect(`/posts/${id}`);
  }

  const updatePostWithId = updatePost.bind(null, post.id);

  return (
    <>
      <Link
        href={`/posts/${id}`}
        className="inline-block mb-6 text-sm text-teal-600 hover:text-teal-700"
      >
        &larr; Back to post
      </Link>

      <h2 className="mb-4 text-3xl font-bold text-gray-900">Edit Post</h2>
      <p className="mb-8 text-gray-600">Editing as {session.user?.email}</p>

      <form action={updatePostWithId} className="p-6 bg-white rounded-lg shadow-md">
        <div className="space-y-4">
          <div>
            <label
              htmlFor="title"
              className="block mb-1 text-sm font-medium text-gray-700"
            >
              Title
            </label>
            <input
              id="title"
              name="title"
              type="text"
              required
              defaultValue={post.title}
              className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-teal-500"
            />
          </div>

          <div>
            <label
              htmlFor="content"
              className="block mb-1 text-sm font-medium text-gray-700"
            >
              Content
            </label>
            <textarea
              id="content"
              name="content"
              required
              rows={8}
              defaultValue={post.content ?? ""}
              className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-teal-500"
            />
          </div>

          <button
            type="submit"
            className="px-4 py-2 text-white bg-teal-600 rounded hover:bg-teal-700"
          >
            Save Changes
          </button>
        </div>
      </form>
    </>
  );
}

Understanding the Edit Page

↑ Index

This page follows the exact same patterns you have already used. Let's highlight what is new.

Pre-filling the form with defaultValue

The create form at /posts/new has empty fields. The edit form pre-fills them with the existing post data:

<input defaultValue={post.title} ... />
<textarea defaultValue={post.content ?? ""} ... />

defaultValue vs value: In React, value makes an input controlled — you need useState and an onChange handler to update it. defaultValue makes it uncontrolled — the browser manages the input state. Since this is a Server Component (no useState), we use defaultValue. The form's FormData will contain whatever the user types, even if they change the pre-filled value.

Server-side ownership check

Before rendering the form, we check that the current user is the author:

if (post.author?.email !== session.user?.email) {
  redirect(`/posts/${id}`);
}

If someone tries to visit /posts/5/edit but they are not the author of post 5, they are silently redirected to the post detail page. There is no error message — they simply cannot access the form.

Defence in depth: Ownership is checked in two places — once in the edit page (to prevent rendering the form) and once in the updatePost action (to prevent the actual database update). Even if someone bypasses the page check (e.g. by crafting a manual POST request), the action will reject it.

The .bind() pattern

const updatePostWithId = updatePost.bind(null, post.id);
<form action={updatePostWithId}>

This is the same .bind() pattern from Step 17 (deleting posts). The post ID is bound as the first argument. When the form is submitted, React passes the FormData as the second argument.


Add the Edit Button to the Post Detail Page

↑ Index

Open app/posts/[id]/page.tsx and add an Edit link next to the Delete button. Find the section where isAuthor conditionally renders the DeleteButton:

{isAuthor && <DeleteButton postId={post.id} />}

Replace it with both buttons:

{isAuthor && (
  <div className="flex items-center gap-2">
    <Link
      href={`/posts/${post.id}/edit`}
      className="px-3 py-1 text-sm text-teal-600 border border-teal-300 rounded hover:bg-teal-50"
    >
      Edit
    </Link>
    <DeleteButton postId={post.id} />
  </div>
)}

You already have Link imported at the top of this file, so no additional import is needed.

Now the post detail page shows both Edit and Delete buttons when the author is viewing their own post.


Verify the Result

↑ Index

Start the dev server if it is not already running:

pnpm dev

Test the update flow:

  1. Sign in and navigate to a post you created.
  2. You should see both Edit and Delete buttons.
  3. Click Edit — the edit page should load with the title and content pre-filled.
  4. Change the title or content and click Save Changes.
  5. You should be redirected to the post detail page with the updated content.
  6. Check the updatedAt timestamp — it should reflect the current time (you can verify in Prisma Studio with npx prisma studio).

Also verify the ownership checks:

  1. Sign in as a different user and visit a post by someone else.
  2. You should not see the Edit or Delete buttons.
  3. Try navigating directly to /posts/[id]/edit for someone else's post — you should be redirected away.

The Complete CRUD Summary

↑ Index

With this step, your app now supports all four CRUD operations:

OperationWhereHow
Create/posts/new<form action={createPost}>prisma.post.create()
Read/posts and /posts/[id]prisma.post.findMany() and prisma.post.findUnique()
Update/posts/[id]/edit<form action={updatePost.bind(null, id)}>prisma.post.update()
Delete/posts/[id] (button)<form action={deletePost.bind(null, id)}>prisma.post.delete()

All four operations:

  • Use Server Actions ("use server") for mutations
  • Check authentication via auth()
  • Enforce ownership (except Read)
  • Use Prisma for database access
  • Redirect after success

File Structure After This Step

↑ Index

app/
├── actions.ts              ← added updatePost
├── posts/
│   ├── [id]/
│   │   ├── page.tsx        ← added Edit link
│   │   ├── edit/
│   │   │   └── page.tsx    ← NEW — edit form
│   │   └── DeleteButton.tsx
│   └── new/
│       └── page.tsx

Summary & Key Takeaways

↑ Index

ConceptWhat it means
prisma.post.update()Updates a record by its unique field. Prisma automatically sets updatedAt thanks to @updatedAt
.bind(null, postId)Pre-fills the first argument of the Server Action. The form's FormData becomes the second argument
defaultValuePre-fills form inputs in an uncontrolled way — works in Server Components without useState
Defence in depthOwnership is checked both in the page (prevent rendering) and in the action (prevent execution)
Edit page routeapp/posts/[id]/edit/page.tsx creates the URL /posts/5/edit — nested inside the dynamic [id] segment
Full CRUDCreate, Read, Update, Delete — all using Server Actions, Prisma, and auth checks