Next.js 14 Server Actions: A Practical Guide for Frontend Developers
Deep dive into Next.js 14 Server Actions with practical examples. Learn how to build efficient, server-side mutations in your React applications.
Introduction
Server Actions are revolutionizing how we handle server-side mutations in Next.js applications. Let's explore practical implementations with real code examples.
Basic Server Action Implementation
Form Handling
// app/actions/todo.ts
'use server'
export async function createTodo(formData: FormData) {
const title = formData.get('title')
await prisma.todo.create({
data: {
title: title as string,
completed: false
}
})
}
// app/components/TodoForm.tsx
export default function TodoForm() {
return (
<form action={createTodo}>
<input type="text" name="title" required />
<button type="submit">Add Todo</button>
</form>
) } ```
### With Client Validation
```typescript
'use client'
import { useFormState, useFormStatus } from "react-dom";
import { createTodo } from "@/app/actions";
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending}>{pending ? "Adding..." : "Add Todo"}</button>) }
export default function TodoFormWithValidation() {
const [state, formAction] = useFormState(createTodo, null)
return (
<form action={formAction}>
<input type="text" name="title" required minLength={3} />
<SubmitButton />
{state?.error && <p className="text-red-500">{state.error}</p>}
</form>
)
}
Advanced Patterns
Optimistic Updates
'use client'
import { experimental_useOptimistic as useOptimistic } from 'react'
export function TodoList({ todos }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo) => [...state, newTodo]
)
async function action(formData: FormData) {
const title = formData.get('title')
addOptimisticTodo({ id: 'temp', title, completed: false })
await createTodo(formData)
}
return (
<div>
<form action={action}>
<input type="text" name="title" />
<button type="submit">Add</button>
</form>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
) } ```
### With Error Boundaries
```typescript
'use client'
import { useCallback } from "react";
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert" className="p-4 bg-red-50 rounded">
<p className="text-red-800">Something went wrong:</p>
<pre className="text-sm">{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
) }
export function TodoFormWithErrorHandling() {
const onReset = useCallback(() => {
// Reset form state
}, [])
return (
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={onReset}>
<TodoForm />
</ErrorBoundary>
) } ```
## Real-World Examples
### File Upload with Progress
```typescript
'use server'
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
export async function getUploadUrl(filename: string) {
const { url, fields } = await createPresignedPost(s3Client, {
Bucket: "my-bucket",
Key: filename,
Expires: 600,
});
return { url, fields };
}
// Client Component
'use client'
export function FileUploader() {
const [progress, setProgress] = useState(0)
async function uploadFile(formData: FormData) {
const file = formData.get('file') as File
const { url, fields } = await getUploadUrl(file.name)
const data = new FormData()
Object.entries(fields).forEach(([key, value]) => {
data.append(key, value as string)
})
data.append('file', file)
await fetch(url, {
method: 'POST',
body: data,
onUploadProgress: (e) => {
setProgress((e.loaded / e.total) * 100)
},
})
}
return (
<form action={uploadFile}>
<input type="file" name="file" />
<button type="submit">Upload</button>
{progress > 0 && <progress value={progress} max="100" />}
</form>
) } ```
### Real-time Search with Debounce
```typescript
'use server'
export async function searchProducts(query: string) {
return await prisma.product.findMany({
where: {
name: {
contains: query,
mode: "insensitive",
},
},
take: 10,
});
}
// Client Component
'use client'
import { useCallback, useState } from 'react'
import { debounce } from 'lodash'
export function SearchProducts() {
const [results, setResults] = useState([])
const debouncedSearch = useCallback(
debounce(async (query: string) => {
const products = await searchProducts(query)
setResults(products)
}, 300),
[]
)
return (
<div>
<input
type="text"
onChange={(e) => debouncedSearch(e.target.value)}
className="border p-2 rounded"
placeholder="Search products..."
/>
<ul className="mt-4">
{results.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
) } ```
## Performance Optimization
### Caching Strategies
```typescript
'use server'
import { cache } from "react";
export const getUser = cache(async (userId: string) => {
const user = await prisma.user.findUnique({
where: { id: userId },
});
return user;
});
// Usage in component
export default async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId)
return (
<div>
<h1>{user.name}</h1>
{/* Other user details */}
</div>
) } ```
## Conclusion
Server Actions provide a powerful way to handle server-side mutations in Next.js applications. By following these patterns and examples, you can build more efficient and maintainable applications.
Key takeaways:
- Use Server Actions for form submissions and data mutations
- Implement optimistic updates for better UX
- Handle errors gracefully with Error Boundaries
- Optimize performance with caching
Remember to check the [Next.js documentation](https://nextjs.org/docs) for the latest updates and best practices.