Next.js course Β· Module 9: Integrations and Advanced Features

Server Actions - advanced patterns

11 min read
In this lesson7

You click "Add task" in the Metropolis Quantum 2150 control panel and... nothing. The list stays frozen because the form is waiting for the server. The user clicks again and a duplicate appears. You already know the basics of Server Actions: a function with the 'use server' directive called straight from a React component, with no separate API endpoint. Now you will learn the patterns that separate a prototype from a reliable application.

Optimistic Updates with useOptimistic

The React 19 useOptimistic hook shows the result of an operation before the server confirms it. It takes the current state and a function that merges it with a change, and returns the state to display plus a function that adds a change. When the action finishes, React goes back to the real data, so on failure the state reverts on its own.

Basic pattern

First we describe a task with the Task interface (id, title, completion flag), then we wire useOptimistic into the form action of a client component:

1// app/tasks/TaskList.tsx
2'use client';
3
4import { useOptimistic } from 'react';
5import { addTask, toggleTask } from './actions';
6
7interface Task {
8  id: string;
9  title: string;
10  completed: boolean;
11}
12
13export default function TaskList({ tasks }: { tasks: Task[] }) {
14  const [optimisticTasks, addOptimisticTask] = useOptimistic(
15    tasks,
16    (state: Task[], newTask: Task) => [...state, newTask]
17  );
18
19  async function handleAddTask(formData: FormData) {
20    const title = formData.get('title') as string;
21
22    // Natychmiastowa aktualizacja UI
23    addOptimisticTask({
24      id: 'temp-' + Date.now(),
25      title,
26      completed: false,
27    });
28
29    // Call the Server Action
30    await addTask(formData);
31  }
32
33  return (
34    <div>
35      <form action={handleAddTask}>
36        <input name="title" placeholder="New task..." />
37        <button type="submit">Add</button>
38      </form>
39
40      <ul>
41        {optimisticTasks.map((task) => (
42          <li key={task.id}>{task.title}</li>
43        ))}
44      </ul>
45    </div>
46  );
47}

The new task appears instantly with a temporary id, and only then do we call addTask. The code does NOT modify the tasks prop: the real list will arrive from the server and replace the temporary version. The function returned by useOptimistic may only be called inside an action or a transition, which is why we call it in handleAddTask.

Server Action with optimistic toggle

On the server side, in a file with 'use server', one function creates a task and the other toggles its state:

1// app/tasks/actions.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { db } from '@/lib/db';
6
7export async function addTask(formData: FormData) {
8  const title = formData.get('title') as string;
9
10  await db.tasks.create({
11    data: { title, completed: false },
12  });
13
14  revalidatePath('/tasks');
15}
16
17export async function toggleTask(taskId: string) {
18  const task = await db.tasks.findUnique({ where: { id: taskId } });
19
20  await db.tasks.update({
21    where: { id: taskId },
22    data: { completed: !task?.completed },
23  });
24
25  revalidatePath('/tasks');
26}

Both save the data and call revalidatePath('/tasks'), so the page is regenerated with a fresh list. You can wire toggleTask to a checkbox with the same optimistic pattern.

Revalidation after a mutation

After data changes, the Next.js cache still remembers the old version of the page. You refresh it by path (revalidatePath) or by tag (revalidateTag). This complements ISR (Incremental Static Regeneration), meaning static generation with periodic updates: ISR refreshes on a timer, on-demand revalidation right after a mutation.

revalidatePath - refreshing a path

The simplest variant takes a page address, and the optional second argument 'layout' covers the layout and every page below it:

1// actions/products.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5
6export async function updateProduct(productId: string, formData: FormData) {
7  const name = formData.get('name') as string;
8  const price = parseFloat(formData.get('price') as string);
9
10  await db.products.update({
11    where: { id: productId },
12    data: { name, price },
13  });
14
15  // Refresh the specific product page
16  revalidatePath('/products/' + productId);
17
18  // Refresh the product list
19  revalidatePath('/products');
20
21  // Refresh the whole layout
22  revalidatePath('/', 'layout');
23}

Revalidation does not fetch the data again right away, it only marks the cache as stale. Fresh data loads on the next visit.

revalidateTag - refreshing by tag

When the same data appears on many pages, tagging it is more convenient. Here the product fetching functions are wrapped in unstable_cache with a list of tags:

1// lib/data.ts
2import { unstable_cache } from 'next/cache';
3
4export const getProducts = unstable_cache(
5  async () => {
6    return await db.products.findMany();
7  },
8  ['products'],
9  { tags: ['products'] }
10);
11
12export const getProduct = unstable_cache(
13  async (id: string) => {
14    return await db.products.findUnique({ where: { id } });
15  },
16  ['product'],
17  { tags: ['products', 'product-detail'] }
18);

In Next.js 16 unstable_cache belongs to the previous model and is replaced by the 'use cache' directive with the cacheTag function, which you will meet in the caching lesson. Tags work the same way in both models.

The action that deletes a product now invalidates all data tagged products with a single call:

1// actions/products.ts
2'use server';
3
4import { revalidateTag } from 'next/cache';
5
6export async function deleteProduct(productId: string) {
7  await db.products.delete({ where: { id: productId } });
8
9  // Refresh all data tagged 'products'
10  revalidateTag('products', 'max');
11}

The second argument 'max' is the profile recommended in Next.js 16: the user gets the stale version while the fresh one loads in the background. Calling it with the tag alone is deprecated. If the user must see their own change immediately, use updateTag('products') in the Server Action.

Error handling in Server Actions

A thrown exception means an error screen for the user. It is better to return a structured result with a success field, an error message and field errors. Validation is handled by Zod, a library that describes the shape of data with a schema.

Error handling pattern

The loginSchema schema checks the email and password, the ActionResult interface describes the result, and the prevState parameter is required by the hook from the next section:

1// actions/auth.ts
2'use server';
3
4import { z } from 'zod';
5
6const loginSchema = z.object({
7  email: z.string().email('Invalid email'),
8  password: z.string().min(8, 'Password must be at least 8 characters'),
9});
10
11interface ActionResult {
12  success: boolean;
13  error?: string;
14  fieldErrors?: Record<string, string[]>;
15}
16
17export async function loginAction(
18  prevState: ActionResult,
19  formData: FormData
20): Promise<ActionResult> {
21  const rawData = {
22    email: formData.get('email'),
23    password: formData.get('password'),
24  };
25
26  // Validate data
27  const validatedFields = loginSchema.safeParse(rawData);
28
29  if (!validatedFields.success) {
30    return {
31      success: false,
32      fieldErrors: validatedFields.error.flatten().fieldErrors,
33    };
34  }
35
36  try {
37    const user = await authenticateUser(
38      validatedFields.data.email,
39      validatedFields.data.password
40    );
41
42    if (!user) {
43      return { success: false, error: 'Invalid login credentials' };
44    }
45
46    // Create the session
47    await createSession(user.id);
48    return { success: true };
49  } catch (error) {
50    return { success: false, error: 'A server error occurred' };
51  }
52}

safeParse does not throw, so validation errors come back as fieldErrors, and try/catch turns server errors into a generic message. In Zod 4 you write this with the newer API: z.email() and z.flattenError(error).

Form component with useActionState

The React 19 useActionState hook takes an action and an initial state, and returns the state, an action for the form and an isPending flag:

1// app/login/page.tsx
2'use client';
3
4import { useActionState } from 'react';
5import { loginAction } from './actions';
6
7export default function LoginForm() {
8  const [state, formAction, isPending] = useActionState(loginAction, {
9    success: false,
10  });
11
12  return (
13    <form action={formAction}>
14      <div>
15        <input name="email" type="email" placeholder="Email" />
16        {state.fieldErrors?.email && (
17          <p className="text-red-500">{state.fieldErrors.email[0]}</p>
18        )}
19      </div>
20
21      <div>
22        <input name="password" type="password" placeholder="Password" />
23        {state.fieldErrors?.password && (
24          <p className="text-red-500">{state.fieldErrors.password[0]}</p>
25        )}
26      </div>
27
28      {state.error && (
29        <div className="bg-red-100 text-red-700 p-3 rounded">
30          {state.error}
31        </div>
32      )}
33
34      <button type="submit" disabled={isPending}>
35        {isPending ? 'Logging in...' : 'Log in'}
36      </button>
37    </form>
38  );
39}

Errors show up under the fields and the button is disabled while submitting, so no more double clicks. The action did not change: React passes it the previous state and the form data.

Progressive Enhancement

Server Actions work even without JavaScript. An HTML form sends data with the POST method by default, so the flow works before the browser runs any scripts.

Form with progressive enhancement

This form is a plain Server Component, with no 'use client' and no hook at all:

1// app/contact/page.tsx
2import { sendMessage } from './actions';
3
4// This form works even with JavaScript disabled!
5export default function ContactForm() {
6  return (
7    <form action={sendMessage}>
8      <input name="name" required placeholder="Name" />
9      <input name="email" type="email" required placeholder="Email" />
10      <textarea name="message" required placeholder="Message" />
11      <button type="submit">Send</button>
12    </form>
13  );
14}

Without JavaScript the browser submits it the classic way, and after hydration Next.js does it without a reload. The action saves the message and redirects:

1// app/contact/actions.ts
2'use server';
3
4import { redirect } from 'next/navigation';
5
6export async function sendMessage(formData: FormData) {
7  const name = formData.get('name') as string;
8  const email = formData.get('email') as string;
9  const message = formData.get('message') as string;
10
11  await db.messages.create({
12    data: { name, email, message },
13  });
14
15  redirect('/contact/success');
16}

redirect works by throwing a special exception, so call it outside a try/catch block, otherwise you swallow it.

Composing Server Actions

In a single action you can combine several steps, and each one can end it early by returning an error:

1// actions/checkout.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { redirect } from 'next/navigation';
6
7export async function processCheckout(formData: FormData) {
8  const cartId = formData.get('cartId') as string;
9
10  // 1. Validate the cart
11  const cart = await validateCart(cartId);
12  if (!cart.valid) {
13    return { error: 'The cart is invalid' };
14  }
15
16  // 2. Process the payment
17  const payment = await processPayment({
18    amount: cart.total,
19    method: formData.get('paymentMethod') as string,
20  });
21
22  if (!payment.success) {
23    return { error: 'Payment failed' };
24  }
25
26  // 3. Create the order
27  const order = await createOrder({
28    cartId,
29    paymentId: payment.id,
30    shippingAddress: formData.get('address') as string,
31  });
32
33  // 4. Refresh data
34  revalidatePath('/orders');
35  revalidatePath('/cart');
36
37  // 5. Redirect to the confirmation
38  redirect('/orders/' + order.id + '/confirmation');
39}

Order matters: validation, payment, order, revalidation, redirect. Every Server Action is a public POST endpoint, so check the session and permissions inside the action, not only in the UI.

File upload with Server Actions

A file input arrives in FormData as a File object. The action checks that it exists, its size and type, and then writes it to disk:

1// actions/upload.ts
2'use server';
3
4import { writeFile } from 'fs/promises';
5import path from 'path';
6
7export async function uploadFile(prevState: unknown, formData: FormData) {
8  const file = formData.get('file') as File;
9
10  if (!file || file.size === 0) {
11    return { error: 'No file selected' };
12  }
13
14  // Validation typu i rozmiaru
15  const maxSize = 5 * 1024 * 1024; // 5MB
16  if (file.size > maxSize) {
17    return { error: 'The file is too large (max 5MB)' };
18  }
19
20  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
21  if (!allowedTypes.includes(file.type)) {
22    return { error: 'File type not allowed' };
23  }
24
25  // Save the file
26  const bytes = await file.arrayBuffer();
27  const buffer = Buffer.from(bytes);
28  const filename = Date.now() + '-' + file.name;
29  const filepath = path.join(process.cwd(), 'public/uploads', filename);
30
31  await writeFile(filepath, buffer);
32
33  return { success: true, url: '/uploads/' + filename };
34}

The prevState parameter is needed because useActionState always passes the previous state as the first argument. The default request body limit of a Server Action is 1 MB, so for files up to 5 MB raise experimental.serverActions.bodySizeLimit in next.config.js.

The client component is short because the hook manages the state:

1// components/FileUpload.tsx
2'use client';
3
4import { useActionState } from 'react';
5import { uploadFile } from '@/actions/upload';
6
7export default function FileUpload() {
8  const [state, formAction, isPending] = useActionState(uploadFile, null);
9
10  return (
11    <form action={formAction}>
12      <input type="file" name="file" accept="image/*" />
13      <button type="submit" disabled={isPending}>
14        {isPending ? 'Uploading...' : 'Upload file'}
15      </button>
16
17      {state?.error && <p className="text-red-500">{state.error}</p>}
18      {state?.success && (
19        <img src={state.url} alt="Uploaded file" className="mt-4 max-w-xs" />
20      )}
21    </form>
22  );
23}

After a successful upload you see an image preview, and on failure the message from the action. No manual fetch, Next.js handles the communication.

Summary

Server Actions are a powerful Next.js mechanism that simplifies client-server communication:

  1. useOptimistic - instant UI update before the server responds
  2. revalidatePath / revalidateTag - precise data revalidation after a mutation
  3. Error handling - structured responses with Zod validation
  4. Progressive enhancement - forms work without JavaScript
  5. Composition - combining many operations into one flow
  6. File upload - native file upload support

My advice: return an object with a success field from your actions instead of throwing, and use useOptimistic where the operation almost always succeeds. In the next lesson you will build tables with TanStack Table, and you will come back to tags with use cache.

Remember: in Metropolis Quantum a good Server Action validates, saves, revalidates and always tells the interface how it went.

Code for this lesson: App.tsx
1import React, { useState, useCallback } from 'react';
2
3interface Task {
4  id: number;
5  title: string;
6  completed: boolean;
7  pending?: boolean;
8}
9
10interface ActionResult {
11  success: boolean;
12  error?: string;
13}
14
15// Simulate async server action
16const simulateServerAction = (ms: number): Promise<ActionResult> =>
17  new Promise(resolve => setTimeout(() => resolve({ success: true }), ms));
18
19const ServerActionsDemo = () => {
20  const [tasks, setTasks] = useState<Task[]>([
21    { id: 1, title: 'Configure revalidatePath', completed: true },
22    { id: 2, title: 'Add useOptimistic', completed: false },
23    { id: 3, title: 'Handle errors in Server Action', completed: false },
24    { id: 4, title: 'Implement file upload', completed: false },
25  ]);
26  const [newTask, setNewTask] = useState('');
27  const [actionLog, setActionLog] = useState<string[]>([]);
28  const [uploading, setUploading] = useState(false);
29  const [uploadResult, setUploadResult] = useState<string | null>(null);
30  const [formError, setFormError] = useState<string | null>(null);
31
32  const addLog = (msg: string) => setActionLog(prev => [...prev.slice(-8), msg]);
33
34  const addTask = async () => {
35    if (!newTask.trim()) return;
36    const tempId = Date.now();
37    // Optimistic update
38    setTasks(prev => [...prev, { id: tempId, title: newTask, completed: false, pending: true }]);
39    addLog('useOptimistic: UI updated instantly');
40    setNewTask('');
41
42    // Simulate server action
43    await simulateServerAction(800);
44    setTasks(prev => prev.map(t => t.id === tempId ? { ...t, pending: false } : t));
45    addLog('Server Action: Task saved, revalidatePath called');
46  };
47
48  const toggleTask = async (id: number) => {
49    // Optimistic toggle
50    setTasks(prev => prev.map(t => t.id === id ? { ...t, completed: !t.completed, pending: true } : t));
51    addLog('useOptimistic: Toggle applied instantly');
52
53    await simulateServerAction(500);
54    setTasks(prev => prev.map(t => t.id === id ? { ...t, pending: false } : t));
55    addLog('Server: revalidateTag("tasks") called');
56  };
57
58  const deleteTask = async (id: number) => {
59    setTasks(prev => prev.filter(t => t.id !== id));
60    addLog('Optimistic: Task removed from UI');
61    await simulateServerAction(400);
62    addLog('Server: Task deleted, cache revalidated');
63  };
64
65  const simulateUpload = async () => {
66    setUploading(true);
67    setUploadResult(null);
68    addLog('Server Action: Processing file upload...');
69    await simulateServerAction(1200);
70    setUploadResult('/uploads/quantum-report-2150.pdf');
71    setUploading(false);
72    addLog('Server: File saved to /uploads/');
73  };
74
75  const simulateFormError = async () => {
76    setFormError(null);
77    addLog('Server Action: Validating form with Zod...');
78    await simulateServerAction(600);
79    setFormError('Email is required. Password must be at least 8 characters.');
80    addLog('Server: Validation failed, returning fieldErrors');
81  };
82
83  return (
84    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: '20px', color: '#fff', fontFamily: 'sans-serif' }}>
85      <h1 style={{ color: '#64ffda', textAlign: 'center' }}>Server Actions - Advanced Patterns</h1>
86      <p style={{ textAlign: 'center', color: '#888', marginBottom: '20px' }}>useOptimistic + revalidation + error handling + file upload</p>
87
88      <div style={{ maxWidth: '800px', margin: '0 auto', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
89        <div>
90          <h3 style={{ color: '#7c4dff', marginBottom: '10px' }}>Optimistic Updates</h3>
91          <div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
92            <input value={newTask} onChange={e => setNewTask(e.target.value)} placeholder="New task..."
93              onKeyDown={e => e.key === 'Enter' && addTask()}
94              style={{ flex: 1, padding: '8px', background: '#16213e', border: '1px solid #444', borderRadius: '6px', color: '#fff' }} />
95            <button onClick={addTask} style={{ padding: '8px 16px', background: '#7c4dff', border: 'none', borderRadius: '6px', color: '#fff', cursor: 'pointer' }}>Add</button>
96          </div>
97
98          <div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: '8px', overflow: 'hidden' }}>
99            {tasks.map(task => (
100              <div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 12px', borderBottom: '1px solid #1a1a2e', opacity: task.pending ? 0.6 : 1 }}>
101                <input type="checkbox" checked={task.completed} onChange={() => toggleTask(task.id)} />
102                <span style={{ flex: 1, textDecoration: task.completed ? 'line-through' : 'none', color: task.completed ? '#555' : '#fff' }}>
103                  {task.title}
104                  {task.pending && <span style={{ color: '#ff9800', fontSize: '11px', marginLeft: '6px' }}>(syncing...)</span>}
105                </span>
106                <button onClick={() => deleteTask(task.id)} style={{ background: 'none', border: 'none', color: '#f44336', cursor: 'pointer' }}>x</button>
107              </div>
108            ))}
109          </div>
110
111          <h3 style={{ color: '#7c4dff', margin: '16px 0 10px' }}>Error Handling</h3>
112          <button onClick={simulateFormError} style={{ padding: '8px 16px', background: '#f44336', border: 'none', borderRadius: '6px', color: '#fff', cursor: 'pointer', marginBottom: '8px' }}>
113            Simulate Form Validation Error
114          </button>
115          {formError && (
116            <div style={{ background: 'rgba(244,67,54,0.15)', border: '1px solid #f44336', padding: '10px', borderRadius: '6px', color: '#f44336', fontSize: '13px' }}>
117              {formError}
118            </div>
119          )}
120
121          <h3 style={{ color: '#7c4dff', margin: '16px 0 10px' }}>File Upload</h3>
122          <button onClick={simulateUpload} disabled={uploading} style={{ padding: '8px 16px', background: uploading ? '#555' : '#4caf50', border: 'none', borderRadius: '6px', color: '#fff', cursor: uploading ? 'default' : 'pointer' }}>
123            {uploading ? 'Uploading...' : 'Upload File'}
124          </button>
125          {uploadResult && <p style={{ color: '#4caf50', fontSize: '13px', marginTop: '6px' }}>Saved: {uploadResult}</p>}
126        </div>
127
128        <div>
129          <h3 style={{ color: '#7c4dff', marginBottom: '10px' }}>Action Log</h3>
130          <div style={{ background: '#000', padding: '12px', borderRadius: '8px', minHeight: '300px', maxHeight: '500px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '11px' }}>
131            {actionLog.length === 0 ? (
132              <span style={{ color: '#555' }}>Interact with the demo to see Server Action logs...</span>
133            ) : actionLog.map((log, i) => (
134              <div key={i} style={{ color: log.includes('Server') ? '#4caf50' : '#64ffda', padding: '3px 0' }}>
135                {log.includes('Server') ? '>> ' : '-> '}{log}
136              </div>
137            ))}
138          </div>
139        </div>
140      </div>
141    </div>
142  );
143};
144
145export default ServerActionsDemo;

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Which React hook allows immediately updating the UI before the server response in Server Actions?

  2. 2. Which Next.js function allows refreshing data based on a cache tag after executing a Server Action?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Write a Server Action that validates form data and returns a structured result with a success field and an optional error.

  • Click in order

    Arrange the implementation of server-side operations in order.

Useful articles