For years, handling data mutations in React applications required a tedious dance: create an API route, write a fetch call on the client, manage loading states, handle errors, revalidate cached data, and show feedback to the user. Each mutation — creating a record, updating a setting, deleting an item — required this same boilerplate across dozens of files. React Server Actions, now stable in Next.js 14, fundamentally simplify this pattern.
At StrikingWeb, Server Actions have become our default approach for handling data mutations in Next.js projects. After six months of using them in production across multiple client applications, we are ready to share what works, what does not, and how to get the most out of this powerful pattern.
What Are Server Actions?
Server Actions are asynchronous functions that execute on the server and can be called directly from your React components. They are defined using the "use server" directive, either at the top of a file (making all exports server actions) or inline within a function body.
Here is a simple example — a server action that creates a new todo item:
// app/actions.ts
"use server"
import { revalidatePath } from "next/cache"
import { db } from "@/lib/database"
export async function createTodo(formData: FormData) {
const title = formData.get("title") as string
await db.todo.create({
data: { title, completed: false }
})
revalidatePath("/todos")
}
This function runs entirely on the server. It has direct access to the database, can use server-side libraries, and the client never sees the implementation details. But it can be invoked from a client-side form as easily as setting the form's action attribute:
// app/todos/page.tsx
import { createTodo } from "@/app/actions"
export default function TodoPage() {
return (
<form action={createTodo}>
<input name="title" placeholder="New todo..." />
<button type="submit">Add</button>
</form>
)
}
No API route. No fetch call. No client-side state management for the mutation. The form works even with JavaScript disabled, because it is a standard HTML form submission under the hood.
Why Server Actions Matter
The significance of Server Actions goes beyond reducing boilerplate. They represent a philosophical shift in how we think about data flow in React applications:
Colocation of Logic
Previously, mutation logic was spread across API routes, client-side fetch functions, and component state. Server Actions let you define the mutation once and use it directly where it is needed. This makes code easier to understand, refactor, and maintain.
Progressive Enhancement
Forms using Server Actions work without JavaScript. The form submits normally, the action executes on the server, and the page is updated. When JavaScript is available, Next.js enhances the experience with client-side navigation, optimistic updates, and loading states. This means your forms are functional even before JavaScript loads — a meaningful improvement for users on slow connections.
Type Safety End-to-End
With TypeScript, Server Actions provide type safety from the component through the server-side logic. There is no JSON serialization boundary to cross, no request/response types to define, and no separate API contract to maintain.
Advanced Patterns
Optimistic Updates with useOptimistic
React's useOptimistic hook pairs perfectly with Server Actions to provide instant UI feedback while the server processes the mutation:
"use client"
import { useOptimistic } from "react"
import { addItem } from "@/app/actions"
export function ItemList({ items }) {
const [optimisticItems, addOptimistic] = useOptimistic(
items,
(state, newItem) => [...state, { ...newItem, pending: true }]
)
async function handleSubmit(formData) {
addOptimistic({ title: formData.get("title") })
await addItem(formData)
}
return (/* render optimisticItems */)
}
The new item appears in the list immediately, styled with a pending state, while the server action processes in the background. If the action fails, the optimistic update is automatically rolled back.
Form Validation with useActionState
The useActionState hook (previously useFormState) provides a clean pattern for server-side validation that reports errors back to the form:
"use server"
export async function createUser(prevState, formData) {
const email = formData.get("email")
if (!email || !email.includes("@")) {
return { error: "Please enter a valid email address" }
}
await db.user.create({ data: { email } })
revalidatePath("/users")
return { success: true }
}
The component uses useActionState to track the action's return value and display validation errors inline, without client-side validation libraries or custom error state management.
Pending States with useFormStatus
The useFormStatus hook provides the pending state of a form submission, enabling loading indicators on submit buttons:
"use client"
import { useFormStatus } from "react-dom"
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending}>
{pending ? "Saving..." : "Save"}
</button>
)
}
Real-World Implementation Patterns
File Organization
We organize server actions in dedicated files grouped by domain. For a project management application, this might look like:
app/actions/projects.ts— createProject, updateProject, deleteProject, archiveProjectapp/actions/tasks.ts— createTask, updateTask, assignTask, completeTaskapp/actions/auth.ts— signIn, signOut, updateProfile
This separation keeps actions discoverable and prevents individual files from growing unwieldy.
Error Handling
We wrap every server action in a consistent error handling pattern:
"use server"
export async function updateProject(formData: FormData) {
try {
const data = parseAndValidate(formData)
await db.project.update({ where: { id: data.id }, data })
revalidatePath(`/projects/${data.id}`)
return { success: true }
} catch (error) {
if (error instanceof ValidationError) {
return { error: error.message, fields: error.fields }
}
console.error("Failed to update project:", error)
return { error: "An unexpected error occurred" }
}
}
Authentication and Authorization
Every server action should verify authentication and authorization before performing any operation. We use a utility function that throws if the user is not authenticated:
"use server"
import { getAuthUser } from "@/lib/auth"
export async function deleteProject(projectId: string) {
const user = await getAuthUser()
const project = await db.project.findUnique({ where: { id: projectId } })
if (project.ownerId !== user.id) {
return { error: "You do not have permission to delete this project" }
}
await db.project.delete({ where: { id: projectId } })
revalidatePath("/projects")
}
"Server Actions are not a silver bullet, but they eliminate an entire class of boilerplate that has plagued React applications for years."
When Not to Use Server Actions
- Real-time updates: For WebSocket-driven real-time features, Server Actions are not the right tool. Use them for the mutation itself, but pair with a real-time subscription for live updates.
- Complex multi-step workflows: If a mutation involves multiple interdependent API calls with rollback logic, an API route with explicit transaction management may be clearer.
- Public APIs: If your mutations need to be accessible from external clients (mobile apps, third-party integrations), you still need API routes. Server Actions are for your Next.js application only.
- Large file uploads: Server Actions are not optimized for streaming large file uploads. Use API routes with streaming support for this use case.
Migration Strategy
If you have an existing Next.js application with API routes for mutations, we recommend a gradual migration approach. Start with new features using Server Actions, then migrate existing API routes one by one, starting with the simplest mutations. Keep API routes for any mutations that are also consumed by external clients.
Server Actions have become one of our most productive tools for Next.js development at StrikingWeb. If you are building a new Next.js application or modernizing an existing one, our web development team can help you design an architecture that leverages Server Actions effectively.