Diving Server Actions in Next.js 14

Learn how to handle server-side operations directly from your components with Next.js 14's powerful Server Actions feature.

What Are Server Actions and Why They Matter

Before Server Actions became stable in Next.js 14, developers typically relied on API routes to handle server-side operations. This approach required creating separate endpoint files, managing HTTP methods, and handling the communication between client and server. While functional, this pattern introduced boilerplate and made it difficult to maintain a clear connection between UI components and their corresponding server logic.

Server Actions solve this problem by allowing you to define asynchronous functions that run exclusively on the server, which can be invoked directly from your React components. The 'use server' directive marks these functions, and Next.js automatically generates the necessary client-side stubs that handle the communication. This approach significantly reduces the amount of code you need to write while improving type safety and developer experience when building modern web applications.

The benefits extend beyond developer convenience. Server Actions enable true progressive enhancement, meaning your forms work even when JavaScript fails to load or is disabled. They also provide automatic protection against cross-site request forgery (CSRF) attacks, as the requests include cryptographic tokens that the server validates. These security features would require significant additional effort to implement manually with traditional API routes.

Key Benefits of Server Actions

Eliminated API Boilerplate

No need to create separate API routes for every server operation

Progressive Enhancement

Forms work even when JavaScript is disabled

Built-in Security

Automatic CSRF protection and server-side execution

Type Safety

Full TypeScript support with excellent developer experience

Cache Revalidation

Easy invalidation of cached data after mutations

Optimistic Updates

Instant UI feedback while server processes requests

Getting Started with Your First Server Action

Creating a Server Action is straightforward--you simply add the 'use server' directive at the top of an asynchronous function. When placed inside a Server Component, you can define and use the action directly. When placed at the top of a file, all exports from that file become Server Actions that can be imported into client components.

The most common pattern for handling form submissions involves creating a function that accepts FormData as its parameter. This object contains all the values from your form fields, accessible through the get() method. This function can then be imported and used in any component. The key advantage is that the function executes entirely on the server--your database credentials, business logic, and validation all remain protected from client-side access, enhancing your overall web application security.

Basic Server Action Example
1// app/actions.ts2'use server'3 4export async function createUser(formData: FormData) {5 const name = formData.get('name')6 const email = formData.get('email')7 8 // Validate the data9 if (!name || !email) {10 throw new Error('Name and email are required')11 }12 13 // Perform server-side operations14 await db.user.create({15 data: { name: name as string, email: email as string }16 })17}

Building Progressive Enhancement Forms

One of Server Actions' most compelling features is their support for progressive enhancement. This means your forms function correctly even without JavaScript, because the 'use server' directive creates a form action that works with traditional HTML form submissions. When JavaScript is available, Next.js enhances this experience with client-side navigation and partial re-rendering. This form works identically whether JavaScript is enabled or not. When JS is available, Next.js provides a seamless experience with loading states and optimistic updates. When JS is disabled, the browser performs a traditional form submission, and the Server Action still processes the request correctly.

To build a form that works with Server Actions, you simply pass the action function to your form's action prop. React handles the submission automatically, invoking the Server Action with the form's data.

Form with Server Action
1// app/page.tsx2import { createUser } from '@/app/actions'3 4export default function UserForm() {5 return (6 <form action={createUser}>7 <label htmlFor="name">Name</label>8 <input type="text" id="name" name="name" required />9 10 <label htmlFor="email">Email</label>11 <input type="email" id="email" name="email" required />12 13 <button type="submit">Create User</button>14 </form>15 )16}

Handling Form State and Validation

Real-world applications require more than basic form submission--they need validation, error handling, and user feedback. Next.js provides hooks like useFormState and useFormStatus to manage these scenarios elegantly. The useFormStatus hook provides information about the form's pending state, while useFormState allows you to access the result of the previous form submission. This pattern allows your Server Action to return validation errors or success messages that the client component can display, creating a smooth feedback loop between server and client.

The useFormStatus hook only works within a component that is itself used within a form element. This is why we extract the button into its own component--hooks can only be called at the top level of React components, and this pattern ensures the hook is used correctly.

Submit Button with Pending State
1// app/components/submit-button.tsx2'use client'3 4import { useFormStatus } from 'react-dom'5 6export function SubmitButton() {7 const { pending } = useFormStatus()8 9 return (10 <button type="submit" disabled={pending}>11 {pending ? 'Submitting...' : 'Submit'}12 </button>13 )14}
Form with useFormState
1// app/components/user-form.tsx2'use client'3 4import { useFormState } from 'react-dom'5import { createUser } from '@/app/actions'6import { SubmitButton } from './submit-button'7 8const initialState = { error: null }9 10export function UserForm() {11 const [state, formAction] = useFormState(createUser, initialState)12 13 return (14 <form action={formAction}>15 <input type="text" name="name" required />16 <input type="email" name="email" required />17 {state?.error && <p className="error">{state.error}</p>}18 <SubmitButton />19 </form>20 )21}

Revalidation and Cache Management

When your Server Action modifies data, the page displaying that data needs to reflect the changes. Next.js provides two functions for this purpose: revalidatePath and revalidateTag. These functions invalidate cached data, forcing Next.js to re-fetch fresh data on the next request.

The revalidatePath function invalidates the cache for a specific route, while revalidateTag allows you to invalidate all data associated with a specific cache tag, which is particularly useful when using fetch with tagged cache entries. This approach ensures that your UI always displays current data without requiring a full page reload or manual cache clearing.

Using revalidatePath
1// app/actions.ts2'use server'3 4import { revalidatePath } from 'next/cache'5import { db } from '@/lib/db'6 7export async function createUser(formData: FormData) {8 const name = formData.get('name')9 const email = formData.get('email')10 11 await db.user.create({12 data: { name: name as string, email: email as string }13 })14 15 // Refresh the users list page16 revalidatePath('/users')17}
Using revalidateTag
1// In your Server Action2export async function updateProduct(id: string, data: ProductData) {3 await db.product.update({ where: { id }, data })4 5 // Invalidate all fetch requests tagged with 'products'6 revalidateTag('products')7}

Organizing Server Actions in Separate Files

While you can define Server Actions directly in components, a cleaner approach is to organize them in separate files. This separation improves code organization, makes actions reusable across multiple components, and keeps your component files focused on presentation logic. This pattern also makes it easier to implement authentication checks and other middleware logic in a central location.

Create an actions directory and define your Server Actions there for better maintainability and to follow best practices for web development.

Organized Server Actions
1// app/actions/user-actions.ts2'use server'3 4import { db } from '@/lib/db'5import { revalidatePath } from 'next/cache'6 7export async function createUser(formData: FormData) {8 const name = formData.get('name')9 const email = formData.get('email')10 11 if (!name || !email) {12 return { error: 'Name and email are required' }13 }14 15 await db.user.create({16 data: { name: name as string, email: email as string }17 })18 19 revalidatePath('/users')20 return { success: true }21}22 23export async function deleteUser(id: string) {24 await db.user.delete({ where: { id } })25 revalidatePath('/users')26}27 28export async function updateUser(id: string, formData: FormData) {29 const name = formData.get('name')30 const email = formData.get('email')31 32 await db.user.update({33 where: { id },34 data: { name: name as string, email: email as string }35 })36 37 revalidatePath('/users')38}

Type Safety with Server Actions

TypeScript integration with Server Actions provides excellent type safety for your form data. When you define a Server Action that accepts FormData, the parameters are accessible as strings, but you can enhance this with type guards and explicit type conversions.

For better type safety, consider defining your form schema using a library like Zod. This approach provides compile-time safety and detailed validation error messages that can be displayed in your form.

Type-Safe Server Action with Zod
1// app/actions.ts2'use server'3 4import { z } from 'zod'5 6const UserSchema = z.object({7 name: z.string().min(2, 'Name must be at least 2 characters'),8 email: z.string().email('Invalid email address'),9})10 11export async function createUser(prevState: unknown, formData: FormData) {12 const validatedFields = UserSchema.safeParse({13 name: formData.get('name'),14 email: formData.get('email'),15 })16 17 if (!validatedFields.success) {18 return {19 errors: validatedFields.error.flatten().fieldErrors,20 }21 }22 23 // TypeScript now knows the exact shape of validatedFields.data24 await db.user.create({ data: validatedFields.data })25 revalidatePath('/users')26 27 return { success: true, message: 'User created successfully' }28}

Security Considerations

Server Actions inherit the security benefits of Next.js's server-side execution model. Your database credentials, API keys, and business logic remain inaccessible to client-side code. However, you should still implement proper authorization checks within your actions.

Every Server Action runs with the same permissions as the server itself, so you must verify that the current user has permission to perform the requested operation. This pattern ensures that even if someone inspects your client-side code, they cannot discover how to access or modify data without proper authorization.

Authorization in Server Actions
1// app/actions.ts2'use server'3 4import { auth } from '@/lib/auth'5import { db } from '@/lib/db'6 7export async function deleteUser(id: string) {8 const session = await auth()9 10 // Verify user is authenticated11 if (!session?.user) {12 throw new Error('Unauthorized')13 }14 15 // Verify user has admin role16 if (session.user.role !== 'admin') {17 throw new Error('Insufficient permissions')18 }19 20 await db.user.delete({ where: { id } })21 revalidatePath('/users')22}

Advanced Patterns and Optimistic Updates

For the best user experience, you can implement optimistic updates that show the expected result immediately while the server processes the request in the background. The useOptimistic hook works alongside Server Actions to provide this functionality.

This pattern dramatically improves perceived performance by eliminating the wait time between user interaction and UI update, resulting in faster, more responsive web applications.

Optimistic Updates with useOptimistic
1// app/components/user-list.tsx2'use client'3 4import { useOptimistic } from 'react'5import { deleteUser } from '@/app/actions'6 7export function UserList({ users }: { users: User[] }) {8 const [optimisticUsers, addOptimisticUser] = useOptimistic(9 users,10 (state, deletedId) => state.filter(user => user.id !== deletedId)11 )12 13 async function handleDelete(id: string) {14 addOptimisticUser(id) // Immediately update UI15 await deleteUser(id) // Process on server16 }17 18 return (19 <ul>20 {optimisticUsers.map(user => (21 <li key={user.id}>22 {user.name}23 <button onClick={() => handleDelete(user.id)}>Delete</button>24 </li>25 ))}26 </ul>27 )28}

Performance Best Practices

Server Actions are designed for efficiency, but there are several practices that ensure optimal performance:

  1. Keep actions focused - Each action should perform a single operation rather than combining multiple unrelated tasks. This approach makes your code more maintainable and allows Next.js to optimize the execution.

  2. Use revalidation strategically - Over-revalidating can cause unnecessary server load, while under-revalidating can lead to stale data. Consider the scope of your changes and invalidate only the affected routes or cache tags.

  3. Leverage streaming - For complex operations, wrap slow portions in startTransition to keep your UI responsive.

  4. Avoid serialization issues - Server Actions can only return serializable data--stick to primitives, arrays, and plain objects.

  5. Handle errors gracefully - Always use try-catch blocks and return user-friendly error messages.

Common Pitfalls and How to Avoid Them

Several common mistakes can cause issues when working with Server Actions:

  • Improper async handling - Always use await when calling async operations within your actions, and handle potential errors with try-catch blocks.

  • Non-serializable return values - Server Actions can only return serializable data--avoid returning complex objects or circular references.

  • Type assumptions with FormData - The FormData.get() method returns FormDataEntryValue | null, which is a union type of string or File. Always check the type before assuming the value is a string, especially when dealing with file uploads.

  • Missing authorization checks - Server Actions run with server permissions, but you must still verify user authorization before performing sensitive operations.

  • Overlooking revalidation - Forgetting to call revalidatePath or revalidateTag after mutations results in stale data being displayed to users.

Conclusion

Server Actions in Next.js 14 represent a significant advancement in how developers handle server-side operations. By eliminating the need for separate API routes, reducing boilerplate code, and providing excellent type safety, they make it easier to build robust, performant web applications. The progressive enhancement support ensures your applications work for all users, while the security features protect your backend infrastructure.

As you incorporate Server Actions into your projects, remember to organize them in separate files for maintainability, implement proper authorization checks, and leverage revalidation strategically to keep your UI in sync with your data. With these practices in mind, Server Actions will become an indispensable tool in your Next.js development toolkit.

The patterns and examples in this guide provide a solid foundation, but the best way to master Server Actions is through practice. Start by refactoring a simple form from API routes to Server Actions, then gradually incorporate more advanced features as you become comfortable with the basics.

Frequently Asked Questions

What is the difference between Server Actions and API routes?

Server Actions allow you to define server-side functions directly in your code that can be called from components, eliminating the need for separate API endpoint files. They provide better type safety, automatic CSRF protection, and support for progressive enhancement.

Can Server Actions be used without JavaScript?

Yes! One of the key benefits of Server Actions is progressive enhancement. When JavaScript is disabled, forms with Server Actions still work through traditional HTML form submissions.

How do I handle authentication with Server Actions?

Server Actions run on the server with full access to authentication state. You can import your auth utilities (like NextAuth.js) and check session data before performing sensitive operations.

When should I use revalidatePath vs revalidateTag?

Use `revalidatePath` when you want to invalidate a specific route. Use `revalidateTag` when you want to invalidate all cached data associated with a particular cache tag, which is useful when using `fetch` with tagged cache entries.

Can Server Actions return non-serializable data?

No, Server Actions can only return serializable data (primitives, arrays, plain objects). This is because the return value needs to be passed between server and client processes.

Ready to Modernize Your Web Applications?

Our team specializes in building high-performance web applications with Next.js and modern React patterns.