Server Actions and forms
Handle mutations without API routes via Server Actions, and polish UX with React 19's `useActionState` · `useFormStatus` · `useOptimistic`. Plus the Part 4 closing mini-project (a guestbook).
Chapter 26 built progressive loading with Suspense and use(). Every line of code we have seen so far only read data. This chapter takes on changing data — mutations — using Next.js’s new weapon, Server Actions.
This chapter shows directly how the client-side form patterns from Chapter 9 (controlled forms) and Chapter 19 (event and form typing) get simpler in the RSC era. The useActionState / useFormStatus / useOptimistic you meet here also reappear in the catalog of Chapter 28 (React 19 features) and become the mutation foundation of Chapter 34 (the fullstack Todo capstone).
The complexity of traditional mutations #
Recall the typical frontend mutation pattern. The controlled form from Chapter 9 plus the typing from Chapter 19 looks roughly like this.
'use client';
import { useState, type FormEvent } from 'react';
export default function CommentForm() {
const [text, setText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error('submit failed');
setText('');
} catch (err) {
setError(err instanceof Error ? err.message : 'unknown error');
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} />
<button disabled={submitting}>{submitting ? 'Submitting...' : 'Submit'}</button>
{error && <p>{error}</p>}
</form>
);
}Boilerplate that repeats every time.
- Build an API endpoint (
/api/comments) - JSON serialize / deserialize
- Handle fetch on the client
- Loading state, error state
- Refetch data after success (update the list)
What if you could express the whole round trip between the API endpoint and the client as one function call? That is what Server Actions enable.
Server Action basics #
A Server Action is an async function with the 'use server' directive. When called from the client, it automatically runs on the server.
'use server';
export async function createComment(text: string) {
// this code always runs on the server
await db.query('INSERT INTO comments (text) VALUES ($1)', [text]);
}'use client';
import { createComment } from './actions';
export default function CommentForm() {
async function handleSubmit(formData: FormData) {
const text = formData.get('text');
if (typeof text === 'string') {
await createComment(text);
}
}
return (
<form action={handleSubmit}>
<input name="text" />
<button>Submit</button>
</form>
);
}The key changes.
- No API route built —
createCommentis just imported and called like a function - No JSON serialization — Next.js handles it
<form action={fn}>— connect the form submission directly to a function (using the browser-native form)- The directive makes the security boundary clear. Only functions tagged with
'use server'can be called from the client
It looks like calling a single function, but internally Next.js builds an RPC (Remote Procedure Call) automatically. The client sends the function ID and arguments to the server, the server runs the actual function and returns the result. The “elegant exception to the serialization constraint” from Chapter 24 is precisely this chapter’s protagonist.
Progressive enhancement #
The <form action={fn}> form still works when JavaScript is disabled. The browser’s native form submission fires, the server receives the request, and the Server Action runs. When JavaScript loads, smooth client-side transitions and pending UI layer on top.
This is the precise meaning of progressive enhancement: the base behavior is guaranteed by HTML standards alone, and the enhancement happens additionally when JavaScript is present. The onSubmit-based form from Chapter 9 dies when JavaScript dies. The <form action={fn}> of Server Actions does not.
You can verify this by enabling Disable JavaScript in dev tools and submitting the form. A POST goes to the same URL, the server processes it, and the page re-renders.
Where the directive lives — file vs function #
'use server' can be used two ways.
1. At the top of the file (every export becomes a Server Action) #
'use server';
export async function createPost(formData: FormData) { /* ... */ }
export async function deletePost(id: string) { /* ... */ }
export async function updatePost(id: string, data: FormData) { /* ... */ }2. Inside a function (defined inline within a Server Component) #
import PostForm from './PostForm';
export default function PostsPage() {
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title');
if (typeof title === 'string') {
await db.insertPost(title);
}
}
return <PostForm onCreate={createPost} />;
}The inline style is convenient because the function can access the Server Component’s closure (parent variables). The downside is that a new function is created on every render, so using it indiscriminately on every list item can be inefficient.
At scale, it is usually better for maintenance to gather actions into a separate file (actions.ts).
Refreshing the page — revalidatePath / revalidateTag #
After a mutation, the screen needs to reflect the new data. Not a plain reload, but invalidating the cache of the affected pages and refetching them. The next.tags / revalidate options from Chapter 25 pair with the invalidation functions in this chapter.
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title');
if (typeof title !== 'string') return;
await db.insertPost(title);
revalidatePath('/posts'); // invalidate the /posts cache
}Calling revalidatePath('/posts') re-renders the page on the next visit to /posts. If the user is already on that page, the screen updates automatically (after a Server Action runs, Next.js refreshes the route’s cache).
revalidateTag pairs with the next.tags option from Chapter 25.
// fetching side (Chapter 25)
const posts = await fetch(url, { next: { tags: ['posts'] } });
// action side (this chapter)
revalidateTag('posts'); // invalidate every fetch tagged 'posts'
Useful when the same data is used across multiple pages and you want to invalidate them in one shot.
useActionState — Actions with state #
The result of an Action (success / failure message, validation errors, etc.) often needs to show on the form. React 19’s new hook useActionState helps.
'use client';
import { useActionState } from 'react';
import { createComment } from './actions';
type State = { message: string };
export default function CommentForm() {
const [state, formAction] = useActionState<State, FormData>(createComment, { message: '' });
return (
<form action={formAction}>
<input name="text" />
<button>Submit</button>
{state.message && <p>{state.message}</p>}
</form>
);
}'use server';
type State = { message: string };
export async function createComment(prevState: State, formData: FormData): Promise<State> {
const text = formData.get('text');
if (typeof text !== 'string' || !text.trim()) {
return { message: 'Please enter some text' };
}
await db.insertComment(text);
return { message: 'Submitted!' };
}useActionState(action, initialState):
- First return (
state): the Action’s last return value (or the initial state) - Second return (
formAction): the wrapped function you pass to<form action={...}> - (There is also a third return
isPendingyou can use to show loading state)
The Action function’s first argument is the previous state, and the second is FormData (which is why the signature in actions.ts above is (prevState, formData)).
Thanks to this pattern, showing validation errors on screen or rendering a success message becomes natural. The State generic from this chapter layers on top of the form typing from Chapter 19.
useFormStatus — show when a submission is in flight #
Whether a form is being submitted (pending) is exposed through the useFormStatus hook.
'use client';
import { useFormStatus } from 'react-dom';
import type { ReactNode } from 'react';
export default function SubmitButton({ children }: { children: ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : children}
</button>
);
}<form action={formAction}>
<input name="text" />
<SubmitButton>Submit</SubmitButton>
</form>useFormStatus reports on the nearest parent form. So wherever you place SubmitButton inside the form, pending is true while that form is submitting.
A similar effect can be achieved with useActionState’s isPending (the third return value), but useFormStatus lets a separate component subscribe to the form’s state, which is useful for patterns like a reusable SubmitButton.
Optimistic UI — useOptimistic #
The pattern of updating the screen before the mutation response arrives, so it feels like it took effect immediately. The useOptimistic hook helps.
'use client';
import { useOptimistic } from 'react';
import { deletePost } from './actions';
type Post = { id: string; title: string };
export default function PostList({ posts }: { posts: Post[] }) {
const [optimisticPosts, deleteOptimistic] = useOptimistic<Post[], string>(
posts,
(state, postId) => state.filter(p => p.id !== postId),
);
async function handleDelete(id: string) {
deleteOptimistic(id); // remove from UI immediately
await deletePost(id); // actual server call
}
return (
<ul>
{optimisticPosts.map(post => (
<li key={post.id}>
{post.title}
<button onClick={() => handleDelete(post.id)}>Delete</button>
</li>
))}
</ul>
);
}useOptimistic(state, reducer) returns an optimistic temporary state and a function to mutate it. Click → immediately remove from UI and start the server call; when the server response refreshes the real state, the two sync up naturally. If the server call fails, the state rolls back automatically.
It is a powerful pattern that improves perceived speed dramatically, but verifying that data displays consistently takes care, so it usually shows up later in learning. This chapter covers only the concept; the actual usage will appear again in the capstone Todo app in Chapter 34.
Try it — guestbook mini project #
Let’s build a small app that combines everything we have learned. A simple guestbook stored in memory (real DB integration is a separate topic and is skipped).
src/app/data.ts (in-memory store):
export type Message = {
id: string;
name: string;
text: string;
createdAt: string;
};
const messages: Message[] = [
{ id: '1', name: 'Admin', text: 'Welcome :)', createdAt: new Date().toISOString() },
];
export async function getMessages(): Promise<Message[]> {
await new Promise(r => setTimeout(r, 200)); // fake delay
return [...messages].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function addMessage(name: string, text: string) {
messages.push({
id: crypto.randomUUID(),
name,
text,
createdAt: new Date().toISOString(),
});
}
export async function deleteMessage(id: string) {
const idx = messages.findIndex(m => m.id === id);
if (idx >= 0) messages.splice(idx, 1);
}src/app/guestbook/actions.ts:
'use server';
import { revalidatePath } from 'next/cache';
import { addMessage, deleteMessage } from '../data';
export type PostState = { error?: string; success?: boolean };
export async function postMessage(prevState: PostState, formData: FormData): Promise<PostState> {
const name = (formData.get('name') ?? '').toString().trim();
const text = (formData.get('text') ?? '').toString().trim();
if (!name) return { error: 'Please enter a name' };
if (!text) return { error: 'Please enter a message' };
if (text.length > 200) return { error: 'Messages must be under 200 characters' };
await addMessage(name, text);
revalidatePath('/guestbook');
return { success: true };
}
export async function removeMessage(id: string) {
await deleteMessage(id);
revalidatePath('/guestbook');
}src/app/guestbook/page.tsx:
import { Suspense } from 'react';
import { getMessages } from '../data';
import MessageForm from './MessageForm';
import { removeMessage } from './actions';
export default function GuestbookPage() {
return (
<div style={{ padding: '24px', maxWidth: '600px', margin: '0 auto' }}>
<h1>Guestbook</h1>
<MessageForm />
<Suspense fallback={<p>Loading messages...</p>}>
<MessageList />
</Suspense>
</div>
);
}
async function MessageList() {
const messages = await getMessages();
if (messages.length === 0) {
return <p>No messages yet. Be the first!</p>;
}
return (
<ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
{messages.map(msg => (
<li key={msg.id} style={{ padding: '12px', borderBottom: '1px solid #eee' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<strong>{msg.name}</strong>
<small style={{ color: '#888' }}>
{new Date(msg.createdAt).toLocaleString('en-US')}
</small>
</div>
<p style={{ margin: '4px 0' }}>{msg.text}</p>
<form action={async () => {
'use server';
await removeMessage(msg.id);
}}>
<button style={{ fontSize: '12px', color: '#888' }}>Delete</button>
</form>
</li>
))}
</ul>
);
}src/app/guestbook/MessageForm.tsx:
'use client';
import { useActionState, useEffect, useRef } from 'react';
import { useFormStatus } from 'react-dom';
import { postMessage, type PostState } from './actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} style={{ padding: '6px 16px' }}>
{pending ? 'Posting...' : 'Post'}
</button>
);
}
const initialState: PostState = {};
export default function MessageForm() {
const [state, formAction] = useActionState(postMessage, initialState);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state]);
return (
<form
ref={formRef}
action={formAction}
style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}
>
<input name="name" placeholder="Name" required style={{ padding: '6px' }} />
<textarea name="text" placeholder="Message" rows={3} required style={{ padding: '6px' }} />
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<SubmitButton />
{state.error && <span style={{ color: 'tomato', fontSize: '14px' }}>{state.error}</span>}
{state.success && <span style={{ color: 'green', fontSize: '14px' }}>Posted!</span>}
</div>
</form>
);
}That is the whole thing. Here is what happens in this tiny app.
GuestbookPage(Server Component): the page shellMessageList(Server Component): renders the message list inside Suspense, with a fallback during loadingMessageForm(Client Component): the form, with useActionState receiving server state and useFormStatus indicating submissionpostMessage(Server Action): validates + stores + revalidatePath on the serverremoveMessage(Server Action): written inline, wired directly to the form’s action
Not a single API endpoint was built. Validation happens on the server so it cannot be bypassed from the client, data sits safely in server memory, and the screen updates automatically after mutation.
Go to /guestbook and try it out. Post a message, try submitting empty (error), try posting over 200 characters (error), and click delete. If you refresh (and the server has not died) the messages are still there — in the in-memory store.
Exercises #
- Verify progressive enhancement. Disable JavaScript in dev tools and submit a message in the guestbook form. Confirm that a plain POST request fires and the page re-renders with the new message. Then explain in one sentence why the
onSubmit-based form from Chapter 9 would not work under the same condition. - Apply
useOptimistic. ApplyuseOptimisticto the delete button in the guestbook above. The message should disappear from the UI immediately on click, and the screen should remain in sync after the server responds. Then deliberately putthrow new Error('failed')insidedeleteMessageand confirm that the UI rolls back to its original state automatically. - Tag-based invalidation experiment. Switch
getMessagesto call an external API withfetch(url, { next: { tags: ['guestbook'] } })(mock it simply), and replacerevalidatePath('/guestbook')inpostMessage/removeMessagewithrevalidateTag('guestbook'). Get a hands-on feel for how Chapter 25’snext.tagsand this chapter’srevalidateTagpair up.
In one line: A Server Action is an async function with
'use server'; calling it from the client automatically runs it on the server.<form action={fn}>gives you progressive enhancement,useActionStatecarries validation results,useFormStatusshows pending state, anduseOptimisticlayers immediate-reflection UX on top. After mutations,revalidatePath/revalidateTagsynchronize with the cache from Chapter 25. API endpoints and JSON-serialization boilerplate disappear, and the client-side forms from Chapters 9 and 19 evolve into the form for the RSC era.
Next chapter #
The main content of Part 4 wraps up with this chapter, but one chapter remains. In Chapter 28 React 19 features, we collect the React 19 features scattered through Part 4 — the use hook, the Actions API (useActionState · useFormStatus · useOptimistic), ref as prop, and React Compiler — into a single catalog. It is the chapter where the pieces of Part 4 come together into a big picture, and it is the true closing of Part 4. Part 5 (operations · testing · deployment) begins at Chapter 29 onward.