Introduction
Server Actions are a paradigm shift in how we handle form submissions and data mutations in React applications. Instead of creating separate API endpoints, you define functions that run directly on the server and can be called from your components. This guide covers Server Actions in both Next.js and Remix.
What are Server Actions?
Server Actions let you define functions that:
- Run on the server
- Can be called from client components
- Handle form submissions
- Return data to the client
- Integrate with server-side validation
graph TB
subgraph "Traditional Approach"
Client1[Client]
API[API Route]
Server1[Server]
Client1 -->|POST| API
API --> Server1
end
subgraph "Server Actions"
Client2[Client]
Action[Server Action]
Client2 -->|call| Action
end
Next.js Server Actions
Defining Server Actions
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
// Validate
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' }
}
// Save to database
const post = await db.post.create({
title,
content,
})
// Revalidate and redirect
revalidatePath('/posts')
redirect(`/posts/${post.id}`)
}
Using in Components
// app/components/CreatePost.tsx
'use client'
import { createPost } from '@/app/actions'
export function CreatePost() {
return (
<form action={createPost}>
<input
type="text"
name="title"
placeholder="Post title"
required
/>
<textarea
name="content"
placeholder="Post content"
/>
<button type="submit">Create Post</button>
</form>
)
}
Validation with Zod
// app/actions.ts
'use server'
import { z } from 'zod'
const schema = z.object({
title: z.string().min(3).max(100),
email: z.string().email(),
age: z.number().min(18).optional(),
})
export async function submitForm(prevState: any, formData: FormData) {
// Extract and validate
const data = {
title: formData.get('title'),
email: formData.get('email'),
age: formData.get('age') ? Number(formData.get('age')) : undefined,
}
const result = schema.safeParse(data)
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
}
}
// Process valid data
await db.user.create(result.data)
return { success: true }
}
useFormState Hook
// app/components/Form.tsx
'use client'
import { useFormState } from 'react-dom'
import { submitForm } from '@/app/actions'
const initialState = {
message: '',
errors: null,
}
export function Form() {
const [state, formAction] = useFormState(submitForm, initialState)
return (
<form action={formAction}>
<div>
<label>Title</label>
<input type="text" name="title" />
{state?.errors?.title && (
<p className="error">{state.errors.title}</p>
)}
</div>
<button type="submit">Submit</button>
{state?.message && <p>{state.message}</p>}
</form>
)
}
useFormStatus
// app/components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
)
}
Mutations with Loading States
// Using useTransition
'use client'
import { updatePost } from '@/app/actions'
import { useState, useTransition } from 'react'
export function EditPost({ post }) {
const [isPending, startTransition] = useTransition()
const [title, setTitle] = useState(post.title)
function handleSubmit(e) {
e.preventDefault()
startTransition(async () => {
await updatePost(post.id, { title })
})
}
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
)
}
Revalidation
'use server'
import { revalidatePath } from 'next/cache'
import { revalidateTag } from 'next/cache'
export async function updatePost(id: string, data: PostData) {
await db.post.update(id, data)
// Revalidate specific path
revalidatePath(`/posts/${id}`)
revalidatePath('/posts')
// Or revalidate by tag
revalidateTag('posts')
}
Remix Server Actions
Basic Actions
// app/routes/posts.new.tsx
import { ActionFunctionArgs, json, redirect } from '@remix-run/node'
import { Form, useActionData } from '@remix-run/react'
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const title = formData.get('title') as string
const content = formData.get('content') as string
// Validation
const errors: Record<string, string> = {}
if (!title || title.length < 3) {
errors.title = 'Title must be at least 3 characters'
}
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 400 })
}
// Create post
const post = await db.post.create({ title, content })
return redirect(`/posts/${post.id}`)
}
export default function NewPost() {
const actionData = useActionData<typeof action>()
return (
<Form method="post">
<div>
<label>Title</label>
<input type="text" name="title" />
{actionData?.errors?.title && (
<span>{actionData.errors.title}</span>
)}
</div>
<div>
<label>Content</label>
<textarea name="content" />
</div>
<button type="submit">Create</button>
</Form>
)
}
useSubmit Hook
// app/components/Search.tsx
import { useSubmit } from '@remix-run/react'
export function Search() {
const submit = useSubmit()
function handleChange(event) {
const isFirstSearch = event.currentTarget.value === ''
submit(event.currentTarget.form, {
replace: !isFirstSearch,
})
}
return (
<Form method="get">
<input
name="q"
type="search"
onChange={handleChange}
/>
</Form>
)
}
Actions with File Upload
// app/routes/upload.tsx
import {
ActionFunctionArgs,
unstable_parseMultipartFormData,
unstable_createMemoryUploadHandler,
} from '@remix-run/node'
export async function action({ request }: ActionFunctionArgs) {
const uploadHandler = unstable_createMemoryUploadHandler({
maxPartSize: 5_000_000, // 5MB
})
const formData = await unstable_parseMultipartFormData(
request,
uploadHandler
)
const file = formData.get('avatar') as File
// Process file...
await saveFile(file)
return json({ success: true })
}
Error Handling
// Using ErrorBoundary
export function ErrorBoundary() {
const error = useRouteError()
if (error instanceof Response) {
return (
<div>
<h1>{error.status} Error</h1>
<p>{error.statusText}</p>
</div>
)
}
return <div>Unknown error occurred</div>
}
Comparison
| Feature | Next.js | Remix |
|---|---|---|
| API | ‘use server’ directive | action export |
| Form Handling | Server Actions | Form component |
| Validation | useFormState | useActionData |
| Pending States | useFormStatus | useNavigation |
| Redirect | redirect() | redirect() |
| Revalidation | revalidatePath | invalidator |
Best Practices
1. Always Validate on Server
// โ
Good - Server handles validation
export async function createUser(formData: FormData) {
const email = formData.get('email')
if (!email || !isValidEmail(email)) {
return { error: 'Invalid email' }
}
await db.user.create({ email })
}
// โ Bad - Trusting client validation
export async function createUser(formData: FormData) {
const email = formData.get('email') // Could be anything!
await db.user.create({ email })
}
2. Handle Errors Gracefully
export async function createPost(formData: FormData) {
try {
await db.post.create({...})
revalidatePath('/posts')
return { success: true }
} catch (error) {
return {
error: 'Failed to create post',
details: error instanceof Error ? error.message : 'Unknown error'
}
}
}
3. Use Progressive Enhancement
// Works without JavaScript!
<Form action={createPost}>
<input name="title" required />
<button type="submit">Create</button>
</Form>
4. Optimize for Mutation
// Use optimistic UI
export function LikeButton({ post }) {
const { pending } = useFetcher()
const isPending = pending || pendingSubmit
return (
<fetcher.Form method="post" action="/like">
<input type="hidden" name="postId" value={post.id} />
<button
disabled={isPending}
className={isPending ? 'loading' : ''}
>
{post.likes + (isPending ? 1 : 0)} Likes
</button>
</fetcher.Form>
)
}
Conclusion
Server Actions simplify data mutations:
- No API routes - mutations directly on server
- Type-safe - full TypeScript support
- Progressive enhancement - works without JS
- Built-in validation - validate on server
Both Next.js and Remix offer excellent Server Action support. Choose based on your framework preference.
Comments