Vibestacks LogoVibestacks
Integrations

Server Actions

Type-safe server actions with next-safe-action. Authentication middleware, Zod validation, and optimistic updates built-in.

Vibestacks includes next-safe-action for type-safe server actions with built-in authentication middleware and Zod validation.

When to Use Server Actions

Decision Guide

Use Server Actions for internal app operations. Use API Routes for external integrations.

Use Server ActionsUse API Routes
Form submissionsWebhooks (Stripe, etc.)
CRUD operations from UIExternal API consumers
Mutations with optimistic UIAuth routes (Better Auth)
Internal app operationsPublic APIs

Action Clients

Three pre-configured clients are available in lib/safe-action.ts:

// No auth required - for public actions
import { actionClient } from "@/lib/safe-action";

// Requires authenticated user
import { authActionClient } from "@/lib/safe-action";

// Requires active subscription
import { subscribedActionClient } from "@/lib/safe-action";

Context Available

Each client provides different context:

ClientContext (ctx)
actionClientNone
authActionClient{ user, session }
subscribedActionClient{ user, session, subscription }

Creating Actions

Create the Action File

Create a new file in lib/actions/:

lib/actions/projects.ts
"use server";

import { z } from "zod";
import { authActionClient } from "@/lib/safe-action";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { projects } from "@/db/schema";

// Define validation schema
const createProjectSchema = z.object({
  name: z.string().min(1, "Name is required").max(100),
  description: z.string().optional(),
});

// Create the action
export const createProject = authActionClient
  .schema(createProjectSchema)
  .action(async ({ parsedInput, ctx }) => {
    const { name, description } = parsedInput;
    const { user } = ctx;

    const [project] = await db
      .insert(projects)
      .values({
        name,
        description,
        userId: user.id,
      })
      .returning();

    revalidatePath("/dashboard/projects");

    return { project };
  });

Use in a Component

components/create-project-form.tsx
"use client";

import { useAction } from "next-safe-action/hooks";
import { createProject } from "@/lib/actions/projects";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

export function CreateProjectForm() {
  const [name, setName] = useState("");

  const { execute, status, result } = useAction(createProject, {
    onSuccess: ({ data }) => {
      setName("");
      console.log("Created project:", data?.project);
    },
    onError: ({ error }) => {
      console.error("Failed:", error);
    },
  });

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      execute({ name });
    }}>
      <Input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Project name"
      />

      <Button disabled={status === "executing"}>
        {status === "executing" ? "Creating..." : "Create Project"}
      </Button>

      {result.serverError && (
        <p className="text-destructive">{result.serverError}</p>
      )}
    </form>
  );
}

Validation Errors

Access field-level validation errors from the result:

const { result } = useAction(createProject);

// Server error (thrown exceptions)
result.serverError // string | undefined

// Validation errors (Zod failures)
result.validationErrors?.name?._errors // string[] | undefined
result.validationErrors?.description?._errors // string[] | undefined

Display them in your form:

<div>
  <Input {...props} />
  {result.validationErrors?.name?._errors?.[0] && (
    <p className="text-sm text-destructive mt-1">
      {result.validationErrors.name._errors[0]}
    </p>
  )}
</div>

Optimistic Updates

For instant UI feedback, update the UI before the server responds:

"use client";

import { useAction } from "next-safe-action/hooks";
import { toggleFavorite } from "@/lib/actions/favorites";
import { useState } from "react";

export function FavoriteButton({ itemId, initialFavorited }) {
  const [isFavorited, setIsFavorited] = useState(initialFavorited);

  const { execute } = useAction(toggleFavorite, {
    onExecute: () => {
      // Optimistically update UI immediately
      setIsFavorited((prev) => !prev);
    },
    onError: () => {
      // Revert on error
      setIsFavorited((prev) => !prev);
    },
  });

  return (
    <button onClick={() => execute({ itemId })}>
      {isFavorited ? "♥ Favorited" : "♡ Favorite"}
    </button>
  );
}

Subscription Gating

Use subscribedActionClient to require an active subscription:

lib/actions/premium.ts
"use server";

import { z } from "zod";
import { subscribedActionClient } from "@/lib/safe-action";

const exportSchema = z.object({
  format: z.enum(["csv", "json", "pdf"]),
});

export const exportData = subscribedActionClient
  .schema(exportSchema)
  .action(async ({ parsedInput, ctx }) => {
    const { format } = parsedInput;
    const { user, subscription } = ctx;

    // User is guaranteed to have active subscription
    console.log("Plan:", subscription?.plan);

    // Generate export...
    return { downloadUrl: `/api/exports/${user.id}` };
  });

If a user without an active subscription calls this action, they'll receive an "Subscription required" error.

Error Handling

Throw errors to return them to the client:

.action(async ({ parsedInput, ctx }) => {
  const project = await db.query.projects.findFirst({
    where: eq(projects.id, parsedInput.id),
  });

  if (!project) {
    throw new Error("Project not found");
  }

  if (project.userId !== ctx.user.id) {
    throw new Error("You don't have access to this project");
  }

  // Continue with operation...
});

The error message will be available in result.serverError.

Cache Revalidation

After mutations, revalidate affected pages:

import { revalidatePath, revalidateTag } from "next/cache";

// Revalidate a specific path
revalidatePath("/dashboard/projects");

// Revalidate all paths with a tag
revalidateTag("projects");

File Organization

Organize actions by domain:

lib/actions/
├── projects.ts      # Project CRUD
├── teams.ts         # Team management
├── settings.ts      # User settings
├── billing.ts       # Subscription changes
└── example.ts       # Reference examples

Example Actions

Vibestacks includes example actions in lib/actions/example.ts:

  • submitContactForm - Public action (no auth)
  • updateProfile - Authenticated action
  • exportUserData - Subscription-gated action
  • toggleFavorite - Optimistic update pattern

Use these as templates for your own actions.

useAction Hook Options

The useAction hook accepts these callbacks:

const { execute, status, result } = useAction(myAction, {
  // Called immediately when execute() is called
  onExecute: () => {},

  // Called when action succeeds
  onSuccess: ({ data, input }) => {},

  // Called when action fails
  onError: ({ error, input }) => {},

  // Called after success or error
  onSettled: ({ result, input }) => {},
});

Status Values

The status value indicates the current state:

StatusDescription
"idle"Initial state, no execution yet
"executing"Action is running
"hasSucceeded"Last execution succeeded
"hasErrored"Last execution failed

Use it for loading states:

<Button disabled={status === "executing"}>
  {status === "executing" ? "Saving..." : "Save"}
</Button>