11.3 Server Actions Types — "use server" and Form Actions
What are Server Actions?
Server Actions are asynchronous functions that run on the server. You can call server-side logic directly from the client without separate API routes.
// app/actions.ts
'use server';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createPost(title: string, content: string) {
const post = await db.post.create({
data: { title, content },
});
revalidatePath('/blog');
return post;
}
Forms and Server Actions
Basic Form Actions
// app/actions.ts
'use server';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
content: z.string().min(10, 'Content must be at least 10 characters'),
published: z.boolean().optional(),
});
type CreatePostInput = z.infer<typeof CreatePostSchema>;
interface ActionResult {
success: boolean;
error?: string;
fieldErrors?: Partial<Record<keyof CreatePostInput, string[]>>;
}
export async function createPost(
prevState: ActionResult | null,
formData: FormData
): Promise<ActionResult> {
// Parse FormData
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on',
};
// Validation
const result = CreatePostSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
fieldErrors: result.error.flatten().fieldErrors,
};
}
try {
await db.post.create({ data: result.data });
revalidatePath('/blog');
redirect('/blog');
} catch (error) {
return {
success: false,
error: 'Failed to create post.',
};
}
}
useActionState (React 19 / Next.js 15+)
// app/blog/new/page.tsx
'use client';
import { useActionState } from 'react';
import { createPost } from '../actions';
export default function NewPostPage() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<div>
<label>Title</label>
<input name="title" required />
{state?.fieldErrors?.title && (
<span className="error">{state.fieldErrors.title[0]}</span>
)}
</div>
<div>
<label>Content</label>
<textarea name="content" rows={10} />
{state?.fieldErrors?.content && (
<span className="error">{state.fieldErrors.content[0]}</span>
)}
</div>
<label>
<input type="checkbox" name="published" />
Publish immediately
</label>
{state?.error && (
<div className="error-banner">{state.error}</div>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}
useFormStatus
Access form submission status in child components.
// components/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
interface SubmitButtonProps {
label?: string;
loadingLabel?: string;
}
export function SubmitButton({
label = 'Save',
loadingLabel = 'Saving...',
}: SubmitButtonProps) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? loadingLabel : label}
</button>
);
}
// Usage — must be inside a form
<form action={serverAction}>
<input name="name" />
<SubmitButton label="Submit" />
</form>
Programmatic Server Action Calls
'use client';
import { useState } from 'react';
import { deletePost } from '@/app/actions';
function DeleteButton({ postId }: { postId: string }) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this?')) return;
setIsDeleting(true);
try {
await deletePost(postId); // Direct Server Action call
} catch (error) {
alert('Failed to delete.');
} finally {
setIsDeleting(false);
}
};
return (
<button onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
);
}
Server Action Type Patterns
Generic Action Result Type
// types/actions.ts
export type ActionState<T = void> =
| { status: 'idle' }
| { status: 'success'; data: T }
| { status: 'error'; message: string; fieldErrors?: Record<string, string[]> };
// Usage
export async function updateProfile(
prevState: ActionState<User>,
formData: FormData
): Promise<ActionState<User>> {
// ...
}
File Upload Action
// actions/upload.ts
'use server';
export async function uploadAvatar(
prevState: { url: string | null; error: string | null },
formData: FormData
) {
const file = formData.get('avatar') as File | null;
if (!file || file.size === 0) {
return { url: null, error: 'Please select a file.' };
}
if (file.size > 5 * 1024 * 1024) {
return { url: null, error: 'File size must be under 5MB.' };
}
if (!file.type.startsWith('image/')) {
return { url: null, error: 'Only image files are allowed.' };
}
try {
const url = await uploadToStorage(file);
return { url, error: null };
} catch {
return { url: null, error: 'Upload failed.' };
}
}
Pro Tips
1. Authentication check in Server Actions
'use server';
import { auth } from '@/lib/auth';
export async function protectedAction(formData: FormData) {
const session = await auth();
if (!session?.user) {
throw new Error('Authentication required.');
}
// Permission check
if (session.user.role !== 'admin') {
throw new Error('Admin access required.');
}
// Actual logic
}
2. Optimistic updates (useOptimistic)
'use client';
import { useOptimistic, useActionState } from 'react';
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
);
const handleAdd = async (formData: FormData) => {
const title = formData.get('title') as string;
// Update UI immediately (before server response)
addOptimisticTodo({ id: 'temp', title, completed: false });
// Save to server
await createTodo(formData);
};
return (
<div>
<form action={handleAdd}>
<input name="title" />
<button type="submit">Add</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}