Next.js Server Actions — Forms Without APIs
Advertisement
Next.js Server Actions — Forms Without APIs
Server Actions let you call server functions directly from Client Components, eliminating the need for API routes for simple form submissions.
- Next.js Server Actions — Forms Without APIs
- Creating Your First Server Action
- Using Server Actions in Forms
- Progressive Enhancement with action
- Form Data Binding
- Handling Errors
- Server Action Validation
- Advanced Patterns
- Optimistic Updates
- Calling Multiple Actions
- Real-World Example: Comment System
- FAQ
Creating Your First Server Action
A Server Action is an async function marked with 'use server':
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(title: string, content: string) {
// This runs on the server
const post = await db.posts.create({ data: { title, content } })
// Revalidate to show the new post
revalidatePath('/blog')
return post
}
Using Server Actions in Forms
Client Component with form:
// app/blog/new/page.tsx
'use client'
import { createPost } from '@/app/actions'
export default function NewPostPage() {
async function handleSubmit(e) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const title = formData.get('title')
const content = formData.get('content')
const result = await createPost(title, content)
console.log('Post created:', result)
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
)
}
Progressive Enhancement with action
Use the action prop for progressive enhancement:
// app/blog/new/page.tsx
import { createPost } from '@/app/actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
)
}
This works even if JavaScript fails to load.
Form Data Binding
With action, form inputs automatically bind to Server Action parameters:
// app/actions.ts
'use server'
export async function updateProfile(formData: FormData) {
const name = formData.get('name')
const email = formData.get('email')
const bio = formData.get('bio')
await db.users.update({
where: { id: getCurrentUserId() },
data: { name, email, bio }
})
revalidatePath('/profile')
}
// app/profile/page.tsx
import { updateProfile } from '@/app/actions'
export default function ProfilePage() {
return (
<form action={updateProfile}>
<input name="name" />
<input name="email" />
<textarea name="bio" />
<button type="submit">Update Profile</button>
</form>
)
}
Handling Errors
Return errors from Server Actions:
// app/actions.ts
'use server'
export async function deletePost(postId: string) {
try {
const post = await db.posts.findUnique({ where: { id: postId } })
if (!post) {
return { error: 'Post not found' }
}
if (post.authorId !== getCurrentUserId()) {
return { error: 'Not authorized' }
}
await db.posts.delete({ where: { id: postId } })
revalidatePath('/blog')
return { success: true }
} catch (error) {
return { error: 'Failed to delete post' }
}
}
// app/blog/[id]/page.tsx
'use client'
import { deletePost } from '@/app/actions'
import { useState } from 'react'
export function DeletePostButton({ postId }: { postId: string }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleDelete() {
setLoading(true)
const result = await deletePost(postId)
if (result.error) {
setError(result.error)
} else {
// Post deleted successfully
window.location.href = '/blog'
}
setLoading(false)
}
return (
<div>
{error && <p className="error">{error}</p>}
<button onClick={handleDelete} disabled={loading}>
{loading ? 'Deleting...' : 'Delete Post'}
</button>
</div>
)
}
Server Action Validation
Validate data before processing:
// app/actions.ts
'use server'
import { z } from 'zod'
const PostSchema = z.object({
title: z.string().min(5, 'Title too short'),
content: z.string().min(10, 'Content too short'),
tags: z.array(z.string()).optional()
})
export async function createPost(formData: FormData) {
const data = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags')
}
const validation = PostSchema.safeParse(data)
if (!validation.success) {
return { errors: validation.error.flatten().fieldErrors }
}
const post = await db.posts.create({ data: validation.data })
revalidatePath('/blog')
return { success: true, post }
}
Advanced Patterns
Optimistic Updates
'use client'
import { updatePostTitle } from '@/app/actions'
import { useState, useTransition } from 'react'
export function EditablePostTitle({ initialTitle, postId }) {
const [optimisticTitle, setOptimisticTitle] = useState(initialTitle)
const [isPending, startTransition] = useTransition()
function handleChange(newTitle) {
setOptimisticTitle(newTitle) // Update UI immediately
startTransition(async () => {
const result = await updatePostTitle(postId, newTitle)
if (result.error) {
setOptimisticTitle(initialTitle) // Revert on error
}
})
}
return (
<input
value={optimisticTitle}
onChange={(e) => handleChange(e.target.value)}
disabled={isPending}
/>
)
}
Calling Multiple Actions
'use client'
import { createPost, sendNotification } from '@/app/actions'
export function CreateWithNotification() {
async function handleCreate(formData: FormData) {
const post = await createPost(
formData.get('title'),
formData.get('content')
)
if (post) {
await sendNotification(
getCurrentUser(),
`New post: ${post.title}`
)
}
}
return <form action={handleCreate}>...</form>
}
Real-World Example: Comment System
// app/actions.ts
'use server'
export async function addComment(postId: string, content: string) {
const userId = getCurrentUserId()
if (!userId) {
return { error: 'Must be logged in' }
}
if (!content.trim()) {
return { error: 'Comment cannot be empty' }
}
const comment = await db.comments.create({
data: {
content,
postId,
authorId: userId,
createdAt: new Date()
}
})
revalidatePath(`/blog/${postId}`)
return { success: true, comment }
}
// app/blog/[id]/comments.tsx
'use client'
import { addComment } from '@/app/actions'
import { useTransition } from 'react'
export function CommentForm({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition()
const [error, setError] = useState('')
async function handleSubmit(e) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
setError('')
startTransition(async () => {
const result = await addComment(postId, formData.get('content'))
if (result.error) {
setError(result.error)
} else {
e.currentTarget.reset()
}
})
}
return (
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<textarea name="content" placeholder="Add a comment..." required />
<button type="submit" disabled={isPending}>
{isPending ? 'Posting...' : 'Post Comment'}
</button>
</form>
)
}
FAQ
Q: Can Server Actions access the database directly? A: Yes, Server Actions run on the server, so they can access databases, call APIs, and use environment variables safely.
Q: What about CORS with Server Actions? A: There's no CORS issue because Server Actions bypass the client-side fetch restrictions—they execute server-to-server.
Q: How do I handle authentication in Server Actions? A: Use session data from your auth library. Check authentication at the start of each action.
Server Actions simplify form handling and reduce boilerplate significantly.
Advertisement