Server Actions enable server-side mutations directly from components. Here's how to use them.
Basic Server Action#
1// app/actions.ts
2'use server';
3
4export async function createUser(formData: FormData) {
5 const name = formData.get('name') as string;
6 const email = formData.get('email') as string;
7
8 // Server-side validation
9 if (!name || !email) {
10 throw new Error('Name and email are required');
11 }
12
13 // Database operation
14 const user = await db.user.create({
15 data: { name, email },
16 });
17
18 return user;
19}
20
21// app/page.tsx
22import { createUser } from './actions';
23
24export default function Page() {
25 return (
26 <form action={createUser}>
27 <input name="name" placeholder="Name" required />
28 <input name="email" type="email" placeholder="Email" required />
29 <button type="submit">Create User</button>
30 </form>
31 );
32}With Form Validation#
1// app/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6const userSchema = z.object({
7 name: z.string().min(2, 'Name must be at least 2 characters'),
8 email: z.string().email('Invalid email address'),
9 password: z.string().min(8, 'Password must be at least 8 characters'),
10});
11
12export type ActionState = {
13 errors?: {
14 name?: string[];
15 email?: string[];
16 password?: string[];
17 _form?: string[];
18 };
19 success?: boolean;
20};
21
22export async function createUser(
23 prevState: ActionState,
24 formData: FormData
25): Promise<ActionState> {
26 const rawData = {
27 name: formData.get('name'),
28 email: formData.get('email'),
29 password: formData.get('password'),
30 };
31
32 const validationResult = userSchema.safeParse(rawData);
33
34 if (!validationResult.success) {
35 return {
36 errors: validationResult.error.flatten().fieldErrors,
37 };
38 }
39
40 try {
41 await db.user.create({
42 data: validationResult.data,
43 });
44
45 return { success: true };
46 } catch (error) {
47 return {
48 errors: {
49 _form: ['Failed to create user. Please try again.'],
50 },
51 };
52 }
53}
54
55// app/components/UserForm.tsx
56'use client';
57
58import { useActionState } from 'react';
59import { createUser, ActionState } from '../actions';
60
61export function UserForm() {
62 const [state, formAction, isPending] = useActionState<ActionState, FormData>(
63 createUser,
64 { errors: {} }
65 );
66
67 return (
68 <form action={formAction}>
69 <div>
70 <input name="name" placeholder="Name" />
71 {state.errors?.name && (
72 <span className="error">{state.errors.name[0]}</span>
73 )}
74 </div>
75
76 <div>
77 <input name="email" type="email" placeholder="Email" />
78 {state.errors?.email && (
79 <span className="error">{state.errors.email[0]}</span>
80 )}
81 </div>
82
83 <div>
84 <input name="password" type="password" placeholder="Password" />
85 {state.errors?.password && (
86 <span className="error">{state.errors.password[0]}</span>
87 )}
88 </div>
89
90 {state.errors?._form && (
91 <div className="error">{state.errors._form[0]}</div>
92 )}
93
94 {state.success && (
95 <div className="success">User created successfully!</div>
96 )}
97
98 <button type="submit" disabled={isPending}>
99 {isPending ? 'Creating...' : 'Create User'}
100 </button>
101 </form>
102 );
103}useFormStatus#
1'use client';
2
3import { useFormStatus } from 'react-dom';
4
5function SubmitButton() {
6 const { pending, data, method, action } = useFormStatus();
7
8 return (
9 <button type="submit" disabled={pending}>
10 {pending ? 'Submitting...' : 'Submit'}
11 </button>
12 );
13}
14
15function FormFields() {
16 const { pending } = useFormStatus();
17
18 return (
19 <fieldset disabled={pending}>
20 <input name="title" placeholder="Title" />
21 <textarea name="content" placeholder="Content" />
22 </fieldset>
23 );
24}
25
26// Usage - SubmitButton must be inside form
27export function Form({ action }: { action: (formData: FormData) => Promise<void> }) {
28 return (
29 <form action={action}>
30 <FormFields />
31 <SubmitButton />
32 </form>
33 );
34}Optimistic Updates#
1'use client';
2
3import { useOptimistic } from 'react';
4import { addTodo, deleteTodo } from './actions';
5
6interface Todo {
7 id: string;
8 text: string;
9 completed: boolean;
10 pending?: boolean;
11}
12
13export function TodoList({ todos }: { todos: Todo[] }) {
14 const [optimisticTodos, addOptimisticTodo] = useOptimistic(
15 todos,
16 (state: Todo[], newTodo: Todo) => [...state, { ...newTodo, pending: true }]
17 );
18
19 async function handleSubmit(formData: FormData) {
20 const text = formData.get('text') as string;
21 const tempId = `temp-${Date.now()}`;
22
23 // Optimistic update
24 addOptimisticTodo({
25 id: tempId,
26 text,
27 completed: false,
28 });
29
30 // Server action
31 await addTodo(formData);
32 }
33
34 return (
35 <div>
36 <form action={handleSubmit}>
37 <input name="text" placeholder="New todo" />
38 <button type="submit">Add</button>
39 </form>
40
41 <ul>
42 {optimisticTodos.map((todo) => (
43 <li
44 key={todo.id}
45 style={{ opacity: todo.pending ? 0.5 : 1 }}
46 >
47 {todo.text}
48 </li>
49 ))}
50 </ul>
51 </div>
52 );
53}Revalidation#
1// app/actions.ts
2'use server';
3
4import { revalidatePath, revalidateTag } from 'next/cache';
5
6export async function createPost(formData: FormData) {
7 const title = formData.get('title') as string;
8 const content = formData.get('content') as string;
9
10 await db.post.create({
11 data: { title, content },
12 });
13
14 // Revalidate specific path
15 revalidatePath('/posts');
16
17 // Or revalidate by tag
18 revalidateTag('posts');
19
20 // Revalidate with layout
21 revalidatePath('/posts', 'layout');
22}
23
24export async function deletePost(id: string) {
25 await db.post.delete({ where: { id } });
26
27 // Revalidate the posts list and specific post
28 revalidatePath('/posts');
29 revalidatePath(`/posts/${id}`);
30}Redirect After Action#
1// app/actions.ts
2'use server';
3
4import { redirect } from 'next/navigation';
5
6export async function createPost(formData: FormData) {
7 const post = await db.post.create({
8 data: {
9 title: formData.get('title') as string,
10 content: formData.get('content') as string,
11 },
12 });
13
14 // Redirect to the new post
15 redirect(`/posts/${post.id}`);
16}
17
18export async function logout() {
19 await auth.signOut();
20 redirect('/login');
21}File Uploads#
1// app/actions.ts
2'use server';
3
4import { writeFile } from 'fs/promises';
5import { join } from 'path';
6
7export async function uploadFile(formData: FormData) {
8 const file = formData.get('file') as File;
9
10 if (!file || file.size === 0) {
11 return { error: 'No file uploaded' };
12 }
13
14 // Validate file type
15 const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
16 if (!allowedTypes.includes(file.type)) {
17 return { error: 'Invalid file type' };
18 }
19
20 // Validate file size (5MB)
21 if (file.size > 5 * 1024 * 1024) {
22 return { error: 'File too large' };
23 }
24
25 const bytes = await file.arrayBuffer();
26 const buffer = Buffer.from(bytes);
27
28 const filename = `${Date.now()}-${file.name}`;
29 const path = join(process.cwd(), 'public', 'uploads', filename);
30
31 await writeFile(path, buffer);
32
33 return { success: true, path: `/uploads/${filename}` };
34}
35
36// Client component
37'use client';
38
39import { useRef, useState } from 'react';
40import { uploadFile } from './actions';
41
42export function FileUpload() {
43 const [preview, setPreview] = useState<string | null>(null);
44 const formRef = useRef<HTMLFormElement>(null);
45
46 function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
47 const file = e.target.files?.[0];
48 if (file) {
49 setPreview(URL.createObjectURL(file));
50 }
51 }
52
53 async function handleSubmit(formData: FormData) {
54 const result = await uploadFile(formData);
55
56 if (result.success) {
57 formRef.current?.reset();
58 setPreview(null);
59 }
60 }
61
62 return (
63 <form ref={formRef} action={handleSubmit}>
64 <input
65 type="file"
66 name="file"
67 accept="image/*"
68 onChange={handleChange}
69 />
70 {preview && <img src={preview} alt="Preview" />}
71 <button type="submit">Upload</button>
72 </form>
73 );
74}Error Handling#
1// app/actions.ts
2'use server';
3
4class ActionError extends Error {
5 constructor(
6 message: string,
7 public code: string
8 ) {
9 super(message);
10 this.name = 'ActionError';
11 }
12}
13
14export async function riskyAction(formData: FormData) {
15 try {
16 const result = await db.something.create({
17 data: { /* ... */ },
18 });
19 return { success: true, data: result };
20 } catch (error) {
21 if (error instanceof PrismaClientKnownRequestError) {
22 if (error.code === 'P2002') {
23 return { error: 'This item already exists' };
24 }
25 }
26 return { error: 'Something went wrong' };
27 }
28}
29
30// With error boundary
31// app/error.tsx
32'use client';
33
34export default function Error({
35 error,
36 reset,
37}: {
38 error: Error & { digest?: string };
39 reset: () => void;
40}) {
41 return (
42 <div>
43 <h2>Something went wrong!</h2>
44 <p>{error.message}</p>
45 <button onClick={reset}>Try again</button>
46 </div>
47 );
48}Inline Server Actions#
1// Define actions inline
2export default function Page() {
3 async function handleSubmit(formData: FormData) {
4 'use server';
5
6 const title = formData.get('title');
7 await db.post.create({ data: { title } });
8 revalidatePath('/posts');
9 }
10
11 return (
12 <form action={handleSubmit}>
13 <input name="title" />
14 <button type="submit">Create</button>
15 </form>
16 );
17}
18
19// With closure over props
20export default function DeleteButton({ id }: { id: string }) {
21 async function handleDelete() {
22 'use server';
23
24 await db.post.delete({ where: { id } });
25 revalidatePath('/posts');
26 }
27
28 return (
29 <form action={handleDelete}>
30 <button type="submit">Delete</button>
31 </form>
32 );
33}Progressive Enhancement#
1// Works without JavaScript
2export default function ContactForm() {
3 async function sendMessage(formData: FormData) {
4 'use server';
5
6 await sendEmail({
7 to: 'contact@example.com',
8 subject: formData.get('subject') as string,
9 body: formData.get('message') as string,
10 });
11
12 redirect('/thank-you');
13 }
14
15 return (
16 <form action={sendMessage}>
17 <input name="subject" placeholder="Subject" required />
18 <textarea name="message" placeholder="Message" required />
19 <button type="submit">Send</button>
20 </form>
21 );
22}Best Practices#
Security:
✓ Always validate input on server
✓ Sanitize user data
✓ Check authentication/authorization
✓ Use CSRF protection (built-in)
Performance:
✓ Keep actions focused
✓ Use revalidation strategically
✓ Consider optimistic updates
✓ Handle loading states
UX:
✓ Show pending states
✓ Provide error feedback
✓ Support progressive enhancement
✓ Handle edge cases
Organization:
✓ Group related actions
✓ Use consistent error handling
✓ Type action returns
✓ Document complex actions
Conclusion#
Server Actions simplify data mutations by eliminating API routes. Use them for forms, mutations, and server-side operations. Combine with useActionState for validation, useOptimistic for instant feedback, and always validate inputs on the server.