{"slug":"nextjs-server-actions","title":"Next.js Server Actions: Form Handling, Validation, and Mutations","tags":["nextjs","server-actions","forms","zod","react","full-stack"],"agent_summary":"Next.js Server Actions for form handling — Zod validation, useActionState hook, optimistic updates, revalidation, and error handling patterns.","trigger_phrases":["server actions","use server","useActionState","form action","server mutation","revalidatePath","Next.js form"],"runnable":false,"markdown":"\n## Overview\n\nServer Actions are async functions that run on the server, triggered directly from client components. They replace API route handlers for mutations in Next.js applications.\n\n## Basic Server Action\n\n```typescript\n// app/actions.ts\n\"use server\";\nimport { revalidatePath } from \"next/cache\";\nimport { z } from \"zod\";\n\nconst CreatePostSchema = z.object({\n  title: z.string().min(1, \"Title is required\").max(200),\n  content: z.string().min(10, \"Content must be at least 10 characters\"),\n  tags: z.string().transform(s => s.split(\",\").map(t => t.trim()).filter(Boolean)),\n});\n\nexport async function createPost(formData: FormData) {\n  const parsed = CreatePostSchema.safeParse(Object.fromEntries(formData));\n  if (!parsed.success) {\n    return { error: parsed.error.flatten() };\n  }\n\n  await db.post.create({ data: parsed.data });\n  revalidatePath(\"/posts\");\n  return { success: true };\n}\n```\n\n## Form with useActionState\n\n```tsx\n// app/posts/new/page.tsx\n\"use client\";\nimport { useActionState } from \"react\";\nimport { createPost } from \"../actions\";\n\ntype State = { error?: { fieldErrors: Record<string, string[]> } } | { success: true } | null;\n\nexport default function NewPost() {\n  const [state, action, isPending] = useActionState<State, FormData>(createPost, null);\n\n  return (\n    <form action={action}>\n      <div>\n        <label htmlFor=\"title\">Title</label>\n        <input id=\"title\" name=\"title\" required />\n        {state?.error?.fieldErrors.title && (\n          <p className=\"error\">{state.error.fieldErrors.title[0]}</p>\n        )}\n      </div>\n      <div>\n        <label htmlFor=\"content\">Content</label>\n        <textarea id=\"content\" name=\"content\" required />\n        {state?.error?.fieldErrors.content && (\n          <p className=\"error\">{state.error.fieldErrors.content[0]}</p>\n        )}\n      </div>\n      <button type=\"submit\" disabled={isPending}>\n        {isPending ? \"Creating...\" : \"Create Post\"}\n      </button>\n    </form>\n  );\n}\n```\n\n## Optimistic Updates\n\n```tsx\n\"use client\";\nimport { useOptimistic } from \"react\";\nimport { toggleLike } from \"./actions\";\n\nfunction PostCard({ post, userId }: { post: Post; userId: string }) {\n  const [optimisticLikes, addOptimistic] = useOptimistic(\n    post.likes,\n    (state, liked: boolean) =>\n      liked\n        ? [...state, { userId }]\n        : state.filter(l => l.userId !== userId)\n  );\n\n  const isLiked = optimisticLikes.some(l => l.userId === userId);\n\n  async function handleLike() {\n    addOptimistic(!isLiked);    // Instant UI update\n    await toggleLike(post.id);  // Server action (actual save)\n  }\n\n  return (\n    <button onClick={handleLike}>\n      {isLiked ? \"Unlike\" : \"Like\"} ({optimisticLikes.length})\n    </button>\n  );\n}\n```\n\n## Authentication Check\n\n```typescript\n\"use server\";\nimport { auth } from \"@/lib/auth\";\nimport { redirect } from \"next/navigation\";\n\nexport async function deletePost(postId: string) {\n  const session = await auth();\n  if (!session?.user) {\n    redirect(\"/login\");\n  }\n\n  // Check ownership\n  const post = await db.post.findUnique({ where: { id: postId } });\n  if (post?.authorId !== session.user.id) {\n    throw new Error(\"Unauthorized\");\n  }\n\n  await db.post.delete({ where: { id: postId } });\n  revalidatePath(\"/posts\");\n}\n```\n\n## Revalidation Strategies\n\n```typescript\n\"use server\";\nimport { revalidatePath, revalidateTag } from \"next/cache\";\n\n// Revalidate a specific route\nrevalidatePath(\"/posts\");\nrevalidatePath(\"/posts/[slug]\", \"page\");  // Dynamic route\n\n// Revalidate all routes with a layout\nrevalidatePath(\"/dashboard\", \"layout\");\n\n// Revalidate by cache tag\nrevalidateTag(\"posts\");        // Any fetch() tagged with \"posts\"\nrevalidateTag(`post-${id}`);   // Specific post\n```\n\n```typescript\n// Tag fetches for targeted revalidation\nconst data = await fetch(\"https://api.example.com/posts\", {\n  next: { tags: [\"posts\"] }\n});\n```\n\n## File Upload Action\n\n```typescript\n\"use server\";\nimport { put } from \"@vercel/blob\";\n\nexport async function uploadFile(formData: FormData) {\n  const file = formData.get(\"file\") as File;\n  if (!file || file.size === 0) {\n    return { error: \"No file provided\" };\n  }\n\n  // Validate type and size\n  if (!file.type.startsWith(\"image/\")) {\n    return { error: \"Only images allowed\" };\n  }\n  if (file.size > 5 * 1024 * 1024) {\n    return { error: \"Max 5MB\" };\n  }\n\n  const blob = await put(file.name, file, { access: \"public\" });\n  return { url: blob.url };\n}\n```\n\n## Common Patterns\n\n```typescript\n// Inline server action (for one-off use)\nexport default function Page() {\n  async function handleDelete(formData: FormData) {\n    \"use server\";\n    const id = formData.get(\"id\") as string;\n    await db.item.delete({ where: { id } });\n    revalidatePath(\"/items\");\n  }\n\n  return (\n    <form action={handleDelete}>\n      <input type=\"hidden\" name=\"id\" value=\"123\" />\n      <button type=\"submit\">Delete</button>\n    </form>\n  );\n}\n\n// Programmatic invocation from client\nconst handleClick = () => startTransition(() => createPost(new FormData()));\n```\n\n## Server Actions vs API Routes\n\n| Use Case | Server Action | API Route |\n|----------|--------------|-----------|\n| Form submission | Yes — simpler DX | Overkill |\n| Mutation from client button | Yes | Works but verbose |\n| External webhook receiver | No | Yes |\n| Third-party API consumption (no auth) | No | Yes |\n| File upload (small) | Yes | Works |\n| Streaming response | No | Yes |\n","html":"<h2>Overview</h2>\n<p>Server Actions are async functions that run on the server, triggered directly from client components. They replace API route handlers for mutations in Next.js applications.</p>\n<h2>Basic Server Action</h2>\n<pre><code class=\"language-typescript\">// app/actions.ts\n\"use server\";\nimport { revalidatePath } from \"next/cache\";\nimport { z } from \"zod\";\n\nconst CreatePostSchema = z.object({\n  title: z.string().min(1, \"Title is required\").max(200),\n  content: z.string().min(10, \"Content must be at least 10 characters\"),\n  tags: z.string().transform(s => s.split(\",\").map(t => t.trim()).filter(Boolean)),\n});\n\nexport async function createPost(formData: FormData) {\n  const parsed = CreatePostSchema.safeParse(Object.fromEntries(formData));\n  if (!parsed.success) {\n    return { error: parsed.error.flatten() };\n  }\n\n  await db.post.create({ data: parsed.data });\n  revalidatePath(\"/posts\");\n  return { success: true };\n}\n</code></pre>\n<h2>Form with useActionState</h2>\n<pre><code class=\"language-tsx\">// app/posts/new/page.tsx\n\"use client\";\nimport { useActionState } from \"react\";\nimport { createPost } from \"../actions\";\n\ntype State = { error?: { fieldErrors: Record&#x3C;string, string[]> } } | { success: true } | null;\n\nexport default function NewPost() {\n  const [state, action, isPending] = useActionState&#x3C;State, FormData>(createPost, null);\n\n  return (\n    &#x3C;form action={action}>\n      &#x3C;div>\n        &#x3C;label htmlFor=\"title\">Title&#x3C;/label>\n        &#x3C;input id=\"title\" name=\"title\" required />\n        {state?.error?.fieldErrors.title &#x26;&#x26; (\n          &#x3C;p className=\"error\">{state.error.fieldErrors.title[0]}&#x3C;/p>\n        )}\n      &#x3C;/div>\n      &#x3C;div>\n        &#x3C;label htmlFor=\"content\">Content&#x3C;/label>\n        &#x3C;textarea id=\"content\" name=\"content\" required />\n        {state?.error?.fieldErrors.content &#x26;&#x26; (\n          &#x3C;p className=\"error\">{state.error.fieldErrors.content[0]}&#x3C;/p>\n        )}\n      &#x3C;/div>\n      &#x3C;button type=\"submit\" disabled={isPending}>\n        {isPending ? \"Creating...\" : \"Create Post\"}\n      &#x3C;/button>\n    &#x3C;/form>\n  );\n}\n</code></pre>\n<h2>Optimistic Updates</h2>\n<pre><code class=\"language-tsx\">\"use client\";\nimport { useOptimistic } from \"react\";\nimport { toggleLike } from \"./actions\";\n\nfunction PostCard({ post, userId }: { post: Post; userId: string }) {\n  const [optimisticLikes, addOptimistic] = useOptimistic(\n    post.likes,\n    (state, liked: boolean) =>\n      liked\n        ? [...state, { userId }]\n        : state.filter(l => l.userId !== userId)\n  );\n\n  const isLiked = optimisticLikes.some(l => l.userId === userId);\n\n  async function handleLike() {\n    addOptimistic(!isLiked);    // Instant UI update\n    await toggleLike(post.id);  // Server action (actual save)\n  }\n\n  return (\n    &#x3C;button onClick={handleLike}>\n      {isLiked ? \"Unlike\" : \"Like\"} ({optimisticLikes.length})\n    &#x3C;/button>\n  );\n}\n</code></pre>\n<h2>Authentication Check</h2>\n<pre><code class=\"language-typescript\">\"use server\";\nimport { auth } from \"@/lib/auth\";\nimport { redirect } from \"next/navigation\";\n\nexport async function deletePost(postId: string) {\n  const session = await auth();\n  if (!session?.user) {\n    redirect(\"/login\");\n  }\n\n  // Check ownership\n  const post = await db.post.findUnique({ where: { id: postId } });\n  if (post?.authorId !== session.user.id) {\n    throw new Error(\"Unauthorized\");\n  }\n\n  await db.post.delete({ where: { id: postId } });\n  revalidatePath(\"/posts\");\n}\n</code></pre>\n<h2>Revalidation Strategies</h2>\n<pre><code class=\"language-typescript\">\"use server\";\nimport { revalidatePath, revalidateTag } from \"next/cache\";\n\n// Revalidate a specific route\nrevalidatePath(\"/posts\");\nrevalidatePath(\"/posts/[slug]\", \"page\");  // Dynamic route\n\n// Revalidate all routes with a layout\nrevalidatePath(\"/dashboard\", \"layout\");\n\n// Revalidate by cache tag\nrevalidateTag(\"posts\");        // Any fetch() tagged with \"posts\"\nrevalidateTag(`post-${id}`);   // Specific post\n</code></pre>\n<pre><code class=\"language-typescript\">// Tag fetches for targeted revalidation\nconst data = await fetch(\"https://api.example.com/posts\", {\n  next: { tags: [\"posts\"] }\n});\n</code></pre>\n<h2>File Upload Action</h2>\n<pre><code class=\"language-typescript\">\"use server\";\nimport { put } from \"@vercel/blob\";\n\nexport async function uploadFile(formData: FormData) {\n  const file = formData.get(\"file\") as File;\n  if (!file || file.size === 0) {\n    return { error: \"No file provided\" };\n  }\n\n  // Validate type and size\n  if (!file.type.startsWith(\"image/\")) {\n    return { error: \"Only images allowed\" };\n  }\n  if (file.size > 5 * 1024 * 1024) {\n    return { error: \"Max 5MB\" };\n  }\n\n  const blob = await put(file.name, file, { access: \"public\" });\n  return { url: blob.url };\n}\n</code></pre>\n<h2>Common Patterns</h2>\n<pre><code class=\"language-typescript\">// Inline server action (for one-off use)\nexport default function Page() {\n  async function handleDelete(formData: FormData) {\n    \"use server\";\n    const id = formData.get(\"id\") as string;\n    await db.item.delete({ where: { id } });\n    revalidatePath(\"/items\");\n  }\n\n  return (\n    &#x3C;form action={handleDelete}>\n      &#x3C;input type=\"hidden\" name=\"id\" value=\"123\" />\n      &#x3C;button type=\"submit\">Delete&#x3C;/button>\n    &#x3C;/form>\n  );\n}\n\n// Programmatic invocation from client\nconst handleClick = () => startTransition(() => createPost(new FormData()));\n</code></pre>\n<h2>Server Actions vs API Routes</h2>\n<p>| Use Case | Server Action | API Route |\n|----------|--------------|-----------|\n| Form submission | Yes — simpler DX | Overkill |\n| Mutation from client button | Yes | Works but verbose |\n| External webhook receiver | No | Yes |\n| Third-party API consumption (no auth) | No | Yes |\n| File upload (small) | Yes | Works |\n| Streaming response | No | Yes |</p>\n"}