← Back to chapters
09intermediate

Server Actions & Forms

Mutate data without API routes. Forms, validation, optimistic updates, and revalidation.

Server Actions: Mutations Without API Routes

Server Actions let you run server-side code directly from your components. No API route needed. Just mark a function with 'use server' and call it from a form or button. It's like magic, but it's actually just an RPC call.

Basic Server Action

src/app/contact/actions.ts
tsx
1// src/app/contact/actions.ts
2"use server";
3
4import { db } from "@/lib/database";
5
6export async function submitContact(formData: FormData) {
7const name = formData.get("name") as string;
8const email = formData.get("email") as string;
9const message = formData.get("message") as string;
10
11await db.insert("messages", { name, email, message });
12
13// You can also revalidate cached data
14// revalidatePath("/messages");
15}
src/app/contact/page.tsx
tsx
1// src/app/contact/page.tsx
2import { submitContact } from "./actions";
3
4export default function ContactPage() {
5return (
6 <form action={submitContact}>
7 <input name="name" placeholder="Name" required />
8 <input name="email" type="email" placeholder="Email" required />
9 <textarea name="message" placeholder="Message" required />
10 <button type="submit">Send Message</button>
11 </form>
12);
13}
šŸ’”

How it works under the hood

When the form submits, Next.js sends a POST request to the server, executes your function, and returns the result. No fetch calls, no API routes, no CORS. It works even with JavaScript disabled (progressive enhancement!).

With Validation & Feedback

src/app/contact/actions.ts
tsx
1// src/app/contact/actions.ts
2"use server";
3
4interface ActionState {
5success: boolean;
6message: string;
7}
8
9export async function submitContact(
10prevState: ActionState,
11formData: FormData
12): Promise<ActionState> {
13const email = formData.get("email") as string;
14
15if (!email.includes("@")) {
16 return { success: false, message: "Invalid email address" };
17}
18
19// Save to database...
20
21return { success: true, message: "Message sent!" };
22}
src/app/contact/ContactForm.tsx
tsx
1"use client";
2
3import { useActionState } from "react";
4import { submitContact } from "./actions";
5
6export default function ContactForm() {
7const [state, action, isPending] = useActionState(submitContact, {
8 success: false,
9 message: "",
10});
11
12return (
13 <form action={action}>
14 <input name="email" type="email" placeholder="Email" required />
15 <button type="submit" disabled={isPending}>
16 {isPending ? "Sending..." : "Send"}
17 </button>
18 {state.message && (
19 <p className={state.success ? "text-green-600" : "text-red-600"}>
20 {state.message}
21 </p>
22 )}
23 </form>
24);
25}
šŸš€

useActionState is the new way

React 19 introduced useActionState (replacing useFormState). It gives you:
• The previous state
• The action function to pass to your form
• An isPending boolean for loading states
Use it for any form that needs loading states or server validation.

ā–¶Watch and Learn