next-safe-action: Type-Safe Server Actions Made Easy

Ready to take your Server Actions game to the next level? Let's dive into next-safe-action and see how it can make your life easier (and your code safer)!

You'll love it if you usually use Zod to validate your actions. next-safe-action will be your new best friend for creating type-safe Server Actions that play nice with React components.

Getting Started: The Basics

First things first, let's get this party started:

npm install next-safe-action

Now, let's create a simple action client:

import { createSafeActionClient } from "next-safe-action";

export const actionClient = createSafeActionClient();

That's it; now you'll be ready to use it in your actions.

Your First Action

Let's start with a basic example – a user registration action:

import { z } from "zod";
import { actionClient } from "./actionClient";

const schema = z.object({
  name: z.string().min(1, "Name is required"),
  email: z.string().email("Invalid email address"),
});

export const registerUser = actionClient
  .use((action) => action)
  .schema(schema)
  .action(async ({ name, email }) => {
    // Pretend we're doing some database magic here
    console.log(`Registering ${name} with email ${email}`);
    return { success: true, message: `Welcome aboard, ${name}!` };
  });

Look at that beautiful Zod schema! 😍 It's making sure our inputs are clean before they even touch our action.

Level Up

Now, if it was just the Zod schema that I found helpful, I wouldn't be so excited. It lets me compose reusable logic that I can easily wrap my actions in.

For a real example, let's kick it up a notch and add some authentication to the mix. We'll create an authActionClient that checks if a user is logged in before running any action:

import { createSafeActionClient } from "next-safe-action";
// I'm using Supabase but you could use whatever your flavor is
import { createClient } from "@/utils/supabase/server";

export const actionClient = createSafeActionClient();

export const authActionClient = actionClient.use(async ({ next }) => {
  const supabase = createClient();
  const { data: user, error } = await supabase.auth.getUser();

  if (error || !user) {
    throw new Error("Oops! Looks like you're not logged in.");
  }
  return next({ ctx: { user } });
});

You'll notice I also passed the user into the context (ctx), making it available in my action now.

Now we can use this authActionClient for actions that require authentication. Let's create an action to update a user's profile:

import { z } from "zod";
import { authActionClient } from "./authActionClient";

const profileSchema = z.object({
  bio: z.string().max(200, "Whoa there! Keep your bio under 200 characters."),
  website: z.string().url("That doesn't look like a valid URL...").optional(),
});

export const updateProfile = authActionClient
  .schema(profileSchema)
  .action(async ({ parsedInput, ctx }) => {
    const { bio, website } = parsedInput;
    const { user } = ctx;

    // Imagine we're updating the user's profile in a database here
    console.log(`Updating profile for user ${user.id}`);
    console.log(`New bio: ${bio}`);
    console.log(`New website: ${website || 'Not provided'}`);

    return {
      success: true,
      message: "Profile updated successfully! Looking good!",
    };
  });

See how we're reusing that authActionClient? It's checking for authentication before our action even runs. Peak efficiency! 🚀

Using Your Shiny New Actions

Now, let's put these actions to work in a React component:

"use client";

import { useAction } from "next-safe-action/hook";
import { registerUser, updateProfile } from "./actions";

export default function UserProfile() {
  const { execute: register, result: registerResult } = useAction(registerUser);
  const { execute: update, result: updateResult } = useAction(updateProfile);

  const handleRegister = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    register({
      name: formData.get("name") as string,
      email: formData.get("email") as string,
    });
  };

  const handleUpdate = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    update({
      bio: formData.get("bio") as string,
      website: formData.get("website") as string,
    });
  };

  return (
    <div>
      <form onSubmit={handleRegister}>
        <input name="name" placeholder="Name" required />
        <input name="email" type="email" placeholder="Email" required />
        <button type="submit">Sign Me Up!</button>
      </form>
      {registerResult.data && <p>{registerResult.data.message}</p>}

      <form onSubmit={handleUpdate}>
        <textarea name="bio" placeholder="Tell us about yourself" required />
        <input name="website" type="url" placeholder="Your cool website" />
        <button type="submit">Update My Profile</button>
      </form>
      {updateResult.data && <p>{updateResult.data.message}</p>}
    </div>
  );
}

It's been very useful for safely parsing data and creating reusable logic for my actions (rather than the tonnes of boilerplate I had been writing until recently).

So, what are you waiting for?

Give next-safe-action a spin and watch your Next.js projects level up! Happy coding! 🚀✨

TypeScriptNextjs
Avatar for Niall Maher

Written by Niall Maher

Founder of Codú - The web developer community! I've worked in nearly every corner of technology businesses: Lead Developer, Software Architect, Product Manager, CTO, and now happily a Founder.

Loading

Fetching comments

Hey! 👋

Got something to say?

or to leave a comment.