Next.js + Vercel Stack6 min read

Next.js Server Actions with Prisma: Complete CRUD Tutorial

Learn to build complete CRUD operations using Next.js Server Actions with Prisma ORM. Step-by-step tutorial with TypeScript, error handling, and production tips.

By John Hashem

Building full-stack applications with Next.js 14+ has become significantly easier with Server Actions. Combined with Prisma ORM, you can create powerful CRUD operations that run securely on the server while maintaining excellent developer experience. This tutorial walks you through implementing complete Create, Read, Update, and Delete functionality using nextjs server actions prisma crud patterns.

Server Actions eliminate the need for separate API routes in many cases, allowing you to write server-side logic directly in your components. When paired with Prisma's type-safe database operations, you get a robust foundation for data management that scales from MVP to production.

Prerequisites

Before starting, ensure you have:

  • Next.js 14+ project with App Router enabled
  • Prisma ORM installed and configured
  • Database connection established (PostgreSQL, MySQL, or SQLite)
  • Basic understanding of TypeScript and React

Step 1: Configure Your Prisma Schema

Start by defining your data model in prisma/schema.prisma. For this tutorial, we'll create a simple blog post model:

model Post {
  id        String   @id @default(cuid())
  title     String
  content   String
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Run npx prisma generate to generate the Prisma client, then npx prisma db push to sync your database schema. This creates the necessary types and database tables for your CRUD operations.

The @default(cuid()) generates unique IDs automatically, while @updatedAt tracks when records change. These conventions make your Server Actions more reliable and easier to debug.

Step 2: Create Server Actions for CRUD Operations

Create a new file lib/actions.ts to house your Server Actions. Each action needs the 'use server' directive and proper error handling:

'use server'

import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  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')
  }
  
  try {
    const post = await prisma.post.create({
      data: {
        title,
        content,
        published: false
      }
    })
    
    revalidatePath('/posts')
    redirect(`/posts/${post.id}`)
  } catch (error) {
    throw new Error('Failed to create post')
  }
}

export async function updatePost(id: string, formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
  const published = formData.get('published') === 'on'
  
  try {
    await prisma.post.update({
      where: { id },
      data: { title, content, published }
    })
    
    revalidatePath('/posts')
    revalidatePath(`/posts/${id}`)
  } catch (error) {
    throw new Error('Failed to update post')
  }
}

export async function deletePost(id: string) {
  try {
    await prisma.post.delete({
      where: { id }
    })
    
    revalidatePath('/posts')
    redirect('/posts')
  } catch (error) {
    throw new Error('Failed to delete post')
  }
}

The revalidatePath function ensures your UI updates immediately after data changes. Without it, users might see stale data until the next page refresh.

Step 3: Implement Read Operations

For reading data, create separate functions that don't need the 'use server' directive since they're not mutating data:

export async function getPosts() {
  try {
    const posts = await prisma.post.findMany({
      orderBy: { createdAt: 'desc' }
    })
    return posts
  } catch (error) {
    throw new Error('Failed to fetch posts')
  }
}

export async function getPost(id: string) {
  try {
    const post = await prisma.post.findUnique({
      where: { id }
    })
    return post
  } catch (error) {
    throw new Error('Failed to fetch post')
  }
}

These functions can be called directly in your Server Components, providing excellent performance since they run on the server without additional network requests.

Step 4: Build Forms with Server Actions

Create a form component that uses your Server Actions. The key is using the action prop instead of traditional onSubmit handlers:

import { createPost } from '@/lib/actions'

export default function CreatePostForm() {
  return (
    <form action={createPost} className="space-y-4">
      <div>
        <label htmlFor="title">Title</label>
        <input
          type="text"
          id="title"
          name="title"
          required
          className="w-full p-2 border rounded"
        />
      </div>
      
      <div>
        <label htmlFor="content">Content</label>
        <textarea
          id="content"
          name="content"
          required
          rows={5}
          className="w-full p-2 border rounded"
        />
      </div>
      
      <button type="submit" className="px-4 py-2 bg-blue-500 text-white rounded">
        Create Post
      </button>
    </form>
  )
}

Server Actions work seamlessly with progressive enhancement. Even without JavaScript, your forms will submit and work correctly, making your application more resilient.

Step 5: Handle Updates and Deletes

For update operations, you'll need to pass the record ID to your Server Action. Use bind to partially apply arguments:

import { updatePost, deletePost } from '@/lib/actions'

export default function EditPostForm({ post }: { post: Post }) {
  const updatePostWithId = updatePost.bind(null, post.id)
  const deletePostWithId = deletePost.bind(null, post.id)
  
  return (
    <div>
      <form action={updatePostWithId} className="space-y-4">
        <input
          type="text"
          name="title"
          defaultValue={post.title}
          required
        />
        <textarea
          name="content"
          defaultValue={post.content}
          required
        />
        <label>
          <input
            type="checkbox"
            name="published"
            defaultChecked={post.published}
          />
          Published
        </label>
        <button type="submit">Update Post</button>
      </form>
      
      <form action={deletePostWithId}>
        <button type="submit" className="text-red-500">
          Delete Post
        </button>
      </form>
    </div>
  )
}

The bind method creates a new function with the ID pre-filled, keeping your Server Action signatures clean while passing necessary data.

Common Mistakes and Troubleshooting

One frequent issue is forgetting revalidatePath after mutations, causing stale data to persist in your UI. Always revalidate any paths that display the modified data. Another common problem occurs when Server Actions don't receive expected form data - ensure your input elements have proper name attributes that match your FormData extraction.

Validation errors can be tricky to handle gracefully. Consider using libraries like Zod for robust input validation, and implement proper error boundaries to catch and display Server Action failures to users.

Database connection issues often surface during development. If you're experiencing intermittent failures, check your connection pooling settings in Prisma and ensure your database can handle the connection load.

Error Handling and Production Considerations

Production applications need robust error handling beyond basic try-catch blocks. Consider implementing structured error responses and logging:

export async function createPost(formData: FormData) {
  try {
    // validation and creation logic
  } catch (error) {
    console.error('Post creation failed:', error)
    // Log to your monitoring service
    throw new Error('Unable to create post. Please try again.')
  }
}

For production deployments, proper database setup and error monitoring become critical. Consider implementing connection pooling, query optimization, and comprehensive logging to maintain performance and reliability.

Next Steps

With your basic CRUD operations working, consider adding features like pagination for large datasets, optimistic updates for better user experience, and input validation using schema validation libraries. You might also want to implement proper authentication to secure your Server Actions and add rate limiting to prevent abuse.

Server Actions with Prisma provide a solid foundation for data management in Next.js applications. The pattern scales well from simple MVPs to complex production applications, especially when combined with proper error handling and security measures.

Ready to build something great?

Let's talk about your project. I offer 1-week MVP sprints, fractional CTO services, and Claude Code consulting.

View All Services