Type-Safe Server Actions in Next.js with Zod

In modern web development, ensuring type safety and data validation across the client-server boundary has long been a challenging aspect of building applications. With the introduction of Server Actions in Next.js 14+ and React 19, together with Zod's validation, developers now have a comprehensive solution for creating type-safe, secure, and maintainable e‑commerce applications.

Server Actions: A New Paradigm

Server Actions represent a paradigm shift in how we handle form submissions and data mutations in React applications. They allow developers to write server-side code directly alongside their client components, creating a seamless bridge between client and server operations. This approach not only simplifies the development process but also enhances security by moving sensitive operations to the server.

Consider a common e‑commerce scenario: a checkout. Traditionally, developers would need to create separate API endpoints, handle form validation on both client and server sides, and manually ensure type consistency across the stack. Server Actions simplify this flow by allowing us to define and execute server-side logic directly from our client components. Zod ensures that the data in the application maintains integrity and type safety.

Let's see how developers traditionally handled form submissions and server interactions and how Server Actions are an improvement.

// Traditional API Route (pages/api/orders.ts)
export async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
try {
const order = await db.orders.create({ data: req.body });
return res.status(200).json(order);
} catch (error) {
return res.status(500).json({ error: 'Failed to create order' });
}
}
}
// Client Component (components/CheckoutForm.tsx)
type OrderData = { /* … */ };

const CheckoutForm = () => {
// State management for form fields
const [formData, setFormData] = useState<OrderData>({
email: '',
items: [],
totalAmount: 0
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null);

try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});

if (!response.ok) {
throw new Error('Failed to submit order');
}

const order = await response.json();
// Handle successful order submission

} catch (error) {
setError(error instanceof Error ? error.message : 'An error occurred');
} finally {
setIsLoading(false);
}
};

return (
<form onSubmit={handleSubmit}>
{/* Form input fields for customer details and order information go here */}
<button type="submit" disabled={isLoading}>
{isLoading ? 'Processing...' : 'Place Order'}
</button>
{error && <div className="error">{error}</div>}
</form>
);
};

Data flow with Server Actions

Server Actions change this pattern by allowing us to have server-side and client-side next to each other without the need to create the API endpoints manually:

// actions.ts
import { z } from 'zod';

const OrderSchema = z.object({
email: z.string().email("Invalid email address"),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().positive()
})),
totalAmount: z.number().positive()
});

export async function submitOrder(formData: FormData) {
'use server';

const rawData = {
email: formData.get('email'),
items: JSON.parse(formData.get('items') as string),
totalAmount: Number(formData.get('totalAmount'))
};

const validatedData = OrderSchema.parse(rawData);

const order = await db.orders.create({ data: validatedData });

revalidatePath('/orders');
return order;
}
// Component with Server Action (app/checkout/page.tsx)
import { submitOrder } from './actions';

const CheckoutForm = () => {
return (
<form action={submitOrder}>
{/* Form input fields for customer details and order information go here */}
<button type="submit">Place Order</button>
</form>
);
};

The difference between these approaches reveals several benefits of Server Actions:

  1. Server Actions eliminate the need for API routes and state management. Much of the friction is removed, which makes the application easier to maintain and reduces the number of bugs.
  2. Server Actions come with built-in progressive enhancement. If JavaScript fails to load or execute, forms will still submit traditionally, and the application remains functional. This is especially important for e‑commerce applications where failed transactions directly impact business revenue.
  3. The colocation of client and server code improves the DX. Instead of jumping between API routes and client components, we can see the complete flow of data. This makes it easier to reason about our application's behavior and maintain consistency.
  4. Server Actions handle data serialization automatically. In the traditional approach, we needed to manually stringify our data for transmission and parse it on the server. Server Actions do this for us, eliminating some of the nasty serialization bugs.

The integration of Zod for schema validation adds another layer of reliability to our Server Actions. By defining our schema with Zod, we ensure that data is validated before it reaches our database. Additionally, TypeScript infers the exact shape of our data, and the same type can be used both in the component and in the server action.

Server Actions in Client Components

Similarly, this action can also be used in Client Components through the React’s useFormState hook:

// app/checkout/page.tsx
'use client';

import { useFormState } from 'react-dom';
import { submitOrder } from './actions';

const initialState = {
success: false,
errors: [],
order: null
};

export function CheckoutForm() {
const [state, formAction] = useFormState(submitOrder, initialState);

return (
<form action={formAction}>
{/* Submit button */}
<button type="submit">Place Order</button>

{state.success && <p>Order placed successfully!</p>}
{state.errors?.length > 0 && <p>Something went wrong</p>}
</form>
);
}

Let's break down what's happening:

  1. Schema Definition: We start by defining exactly what our order data should look like using Zod. This schema serves three purposes:

    • It can validate our data at runtime
    • TypeScript can infer types automatically, so the IDE can help us catch errors before our code reaches production
    • It provides clear error messages that we can show to customers during checkout
  2. Server Action: When a customer submits an order, the Server Action springs into action. It:

    • Takes the raw form data from the checkout and converts it into a format our schema can understand
    • Validates this data against our schema, ensuring all details are correct
    • If the data is valid, it creates the order in our database
    • If something goes wrong, returns helpful error messages to guide the customer
  3. Client Component:

    • Uses the useFormState hook to handle submission
    • Shows validation errors if the details are incorrect
    • Displays a success message when the order is placed successfully

This approach creates a robust flow that combines the simplicity of traditional HTML forms with the security of modern type safety and validation. The Server Action manages all the complex order processing and validation logic while our components stay focused on providing a smooth checkout experience for customers.

This flow is even further simplified thanks to next-safe-action that we’ll talk about in just a second.

Benefits of Server Actions

This integration of type safety with Server Actions benefits the developers in several ways:

  1. Early Error Detection When you write a Server Action, TypeScript and Zod work together to catch potential issues before they reach production.

  2. Improved IDE Support The combination of TypeScript and Zod provides rich autocompletion and inline documentation.

  3. Self-Documenting Code Zod schemas serve as both runtime validation and documentation of your data structures.

By combining Server Actions with strong type safety, we create a development environment where errors are caught early, data flows are predictable, and business logic is clearly expressed in our code.

Enhancing Type Safety with next-safe-action

When building e‑commerce applications, handling order submissions securely and reliably is crucial. While Server Actions provide a solid foundation, next-safe-action and its useAction hook take it a step further.

next-safe-action role is to tighten the type-safety in our application and simplify the data flow even further. It natively supports Zod and hides all the implementation details from our eyes. Here's how we can implement order processing using this enhanced approach:

// app/actions/orders.ts
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';

// Define the schema for order processing
const OrderSchema = /* … */

// Initialize the action client
const actionClient = createSafeActionClient();

// Create a type-safe action for order submission
export const submitOrder = actionClient
.schema(OrderSchema)
.action(async ({ parsedInput }) => {
const order = await db.orders.create({ data: parsedInput });
return order;
});

parsedInput here is the data automatically parsed by the provided Zod schema. How convenient!

The useAction hook provides complete control over action execution within client components:

// app/checkout/page.tsx
'use client';

import { useAction } from 'next-safe-action/hooks';
import { submitOrder } from './actions’';

export function CheckoutForm() {
const { execute, status, result } = useAction(submitOrder);

return (
<form action={execute}>
{/* Form input fields for order details go here */}
<button type="submit" disabled={status === 'executing'}>
{status === 'executing' ? 'Processing Order...' : 'Place Order'}
</button>

{status === 'hasSucceeded' && (
<p className="text-green-500">Order placed successfully!</p>
)}

{status === 'hasErrored' && (
<p className="text-red-500">{result?.serverError}</p>
)}

{/* `result?.validationErrors` can be used to display validation errors for specific fields */}
</form>
);
}

By combining next-safe-action with Server Actions and Zod, this setup:

  • Validates all order data before processing
  • Provides feedback during checkout
  • Handles errors (server or validation) gracefully
  • Maintains type safety throughout the entire order flow
  • Parses FormData and eliminates the need to convert it one way or another manually

Conclusion

The combination of Server Actions, Zod, and next-safe-action creates a solid foundation for building modern e‑commerce applications with Next.js 15 and React 19. This approach represents a shift in how we think about building web applications that are both robust and developer-friendly.

By eliminating the traditional separation between client and server code, Server Actions allow developers to think in a simple mental model about their applications. The addition of Zod's schema validation and the elegant abstractions provided by next-safe-action further enhances this approach. The type safety provided by this combination helps prevent common errors that could be costly in production.