Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
22 minLesson 20 of 33
Server & Client Components

Server Actions & Form Handling

Server Actions & Form Handling

Server Actions are one of the most significant features in modern Next.js. They let you write server-side functions that can be called directly from your React components — no API routes, no fetch calls, no boilerplate. You write a function, mark it "use server", and call it like any other async function.

What Server Actions Are

A Server Action is an async function marked with "use server". When a Client Component calls this function, Next.js automatically makes an HTTP request to the server and runs the function there. From your code's perspective, it looks like a regular function call.

// src/app/actions.ts
"use server";

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

export async function createCourse(title: string, description: string) {
    const course = await db.course.create({
        data: { title, description, slug: slugify(title) },
    });
    
    revalidatePath("/courses");   // invalidate the courses page cache
    return course;
}
// src/app/courses/new/page.tsx
"use client";
import { createCourse } from "@/app/actions";

export default function NewCoursePage() {
    async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
        await createCourse(
            formData.get("title") as string,
            formData.get("description") as string
        );
    }
    
    return (
        <form onSubmit={handleSubmit}>
            <input name="title" placeholder="Course title" required />
            <textarea name="description" placeholder="Description" />
            <button type="submit">Create Course</button>
        </form>
    );
}

Native Form Actions

The most powerful pattern: pass a Server Action directly to a form's action attribute. Works without JavaScript enabled (progressive enhancement):

// src/app/actions.ts
"use server";

import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";

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");
    }
    
    const post = await db.post.create({
        data: {
            title,
            content,
            slug: generateSlug(title),
            authorId: "current-user-id",  // from session
        },
    });
    
    revalidatePath("/blog");
    redirect(`/blog/${post.slug}`);   // redirect after success
}
// src/app/blog/new/page.tsx — Server Component, no "use client" needed
import { createPost } from "@/app/actions";

export default function NewPostPage() {
    return (
        <main className="max-w-2xl mx-auto px-4 py-12">
            <h1 className="text-3xl font-bold mb-8">Write a Post</h1>
            
            {/* action prop accepts a Server Action */}
            <form action={createPost} className="space-y-5">
                <div>
                    <label className="block text-sm font-medium mb-1">Title</label>
                    <input 
                        name="title" 
                        required 
                        className="w-full border rounded-lg px-3 py-2"
                        placeholder="My awesome post"
                    />
                </div>
                <div>
                    <label className="block text-sm font-medium mb-1">Content</label>
                    <textarea 
                        name="content" 
                        rows={10} 
                        required
                        className="w-full border rounded-lg px-3 py-2"
                    />
                </div>
                <button 
                    type="submit"
                    className="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-5 py-2.5 rounded-lg transition-colors"
                >
                    Publish Post
                </button>
            </form>
        </main>
    );
}

No JavaScript required for this form to work. The browser submits the form natively, Next.js intercepts it on the server, and calls your action.

useFormState — Handling Validation Errors

For form validation feedback, use useFormState to pass state back from the Server Action:

// src/app/actions.ts
"use server";

interface FormState {
    errors?: { title?: string; content?: string };
    success?: boolean;
}

export async function createPost(prevState: FormState, formData: FormData): Promise<FormState> {
    const title = formData.get("title") as string;
    const content = formData.get("content") as string;
    
    // Validate
    const errors: FormState["errors"] = {};
    if (!title || title.length < 3) errors.title = "Title must be at least 3 characters";
    if (!content || content.length < 10) errors.content = "Content must be at least 10 characters";
    
    if (Object.keys(errors).length > 0) {
        return { errors };
    }
    
    await db.post.create({ data: { title, content, slug: generateSlug(title) } });
    
    revalidatePath("/blog");
    return { success: true };
}
"use client";

import { useFormState, useFormStatus } from "react-dom";
import { createPost } from "@/app/actions";

function SubmitButton() {
    const { pending } = useFormStatus();
    
    return (
        <button
            type="submit"
            disabled={pending}
            className={`bg-blue-600 text-white font-semibold px-5 py-2.5 rounded-lg ${
                pending ? "opacity-75 cursor-not-allowed" : "hover:bg-blue-700"
            }`}
        >
            {pending ? "Publishing..." : "Publish Post"}
        </button>
    );
}

export default function NewPostForm() {
    const [state, formAction] = useFormState(createPost, {});
    
    if (state.success) {
        return <p className="text-green-600 font-medium">Post published!</p>;
    }
    
    return (
        <form action={formAction} className="space-y-5">
            <div>
                <label className="block text-sm font-medium mb-1">Title</label>
                <input name="title" className={`w-full border rounded-lg px-3 py-2 ${state.errors?.title ? "border-red-400" : "border-gray-300"}`} />
                {state.errors?.title && (
                    <p className="text-red-600 text-sm mt-1">{state.errors.title}</p>
                )}
            </div>
            <div>
                <label className="block text-sm font-medium mb-1">Content</label>
                <textarea name="content" rows={8} className={`w-full border rounded-lg px-3 py-2 ${state.errors?.content ? "border-red-400" : "border-gray-300"}`} />
                {state.errors?.content && (
                    <p className="text-red-600 text-sm mt-1">{state.errors.content}</p>
                )}
            </div>
            <SubmitButton />
        </form>
    );
}

useFormStatus gives you the pending state — true while the Server Action is running. useFormState gives you the return value of the action for displaying validation errors.

Server Actions for Mutations Without Forms

Use Server Actions for any server-side mutation — not just forms:

// src/app/actions.ts
"use server";

export async function deletePost(id: string) {
    await db.post.delete({ where: { id } });
    revalidatePath("/blog");
}

export async function togglePublished(id: string) {
    const post = await db.post.findUnique({ where: { id }, select: { published: true } });
    await db.post.update({
        where: { id },
        data: { published: !post!.published },
    });
    revalidatePath("/blog");
    revalidatePath(`/blog/${id}`);
}
"use client";
import { deletePost, togglePublished } from "@/app/actions";

function PostActions({ post }: { post: Post }) {
    return (
        <div className="flex gap-2">
            <button
                onClick={() => togglePublished(post.id)}
                className={post.published ? "text-yellow-600" : "text-green-600"}
            >
                {post.published ? "Unpublish" : "Publish"}
            </button>
            <button
                onClick={async () => {
                    if (confirm("Delete this post?")) {
                        await deletePost(post.id);
                    }
                }}
                className="text-red-600"
            >
                Delete
            </button>
        </div>
    );
}

Inline Server Actions

Define Server Actions inline inside Server Components with "use server":

// src/app/todo/page.tsx — Server Component
export default async function TodoPage() {
    const todos = await db.todo.findMany();
    
    // Inline Server Action
    async function addTodo(formData: FormData) {
        "use server";
        const text = formData.get("text") as string;
        await db.todo.create({ data: { text } });
        revalidatePath("/todo");
    }
    
    async function deleteTodo(id: string) {
        "use server";
        await db.todo.delete({ where: { id } });
        revalidatePath("/todo");
    }
    
    return (
        <div>
            <form action={addTodo} className="flex gap-2 mb-6">
                <input name="text" className="border rounded px-3 py-2 flex-1" />
                <button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">Add</button>
            </form>
            <ul className="space-y-2">
                {todos.map(todo => (
                    <li key={todo.id} className="flex items-center justify-between p-3 bg-white rounded border">
                        <span>{todo.text}</span>
                        <form action={deleteTodo.bind(null, todo.id)}>
                            <button type="submit" className="text-red-500 text-sm">Delete</button>
                        </form>
                    </li>
                ))}
            </ul>
        </div>
    );
}

All the data access, mutation, and cache invalidation in one file, with no API routes. This is the simplest possible form of full-stack development.

Next lesson: Streaming and Suspense — sending data progressively to the browser as it becomes ready.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!