LogoStarterkitpro
Walkthrough

Server Actions

Learn how to implement public, protected, and paid Server Actions for data mutations.

Server Actions allow you to define functions that run directly on the server, typically triggered by form submissions or client-side events, without needing to create separate API endpoints. They are defined using the "use server"; directive at the top of the file or function.

Public Server Action

These actions can be called by anyone, authenticated or not.

Example Backend Action (app/actions/feedback.ts):

app/actions/feedback.ts
"use server";
 
import { z } from "zod";
import { prisma } from "@/lib/db";
 
const feedbackSchema = z.object({
  message: z.string().min(10, "Message must be at least 10 characters"),
});
 
export async function submitFeedback(formData: FormData) {
  const validatedFields = feedbackSchema.safeParse({
    message: formData.get("message"),
  });
 
  if (!validatedFields.success) {
    return {
      status: "error",
      message: "Invalid input",
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }
 
  try {
    await prisma.feedback.create({
      data: {
        message: validatedFields.data.message,
      },
    });
    return { status: "success", message: "Feedback submitted successfully!" };
  } catch (error) {
    console.error("Feedback submission error:", error);
    return { status: "error", message: "Failed to submit feedback." };
  }
}

Example Frontend Invocation (useTransition):

components/feedback-form.tsx
"use client";
 
import { useTransition } from "react";
import { submitFeedback } from "@/app/actions/feedback";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
 
function FeedbackForm() {
  const [isPending, startTransition] = useTransition();
 
  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
 
    startTransition(async () => {
      const result = await submitFeedback(formData);
      if (result.status === "success") {
        toast.success(result.message);
        event.currentTarget.reset(); // Clear form
      } else {
        // Basic error display (could be more detailed)
        toast.error(result.message || "An error occurred");
      }
    });
  };
 
  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <Textarea
        name="message"
        placeholder="Your feedback..."
        required
        minLength={10}
      />
      <Button type="submit" disabled={isPending}>
        {isPending ? "Submitting..." : "Submit Feedback"}
      </Button>
    </form>
  );
}
 
export default FeedbackForm;

Protected Server Action

Always Check Authentication!

While your UI might prevent unauthenticated users from reaching a component, Server Actions are like API endpoints and can be triggered directly. It is critical to ALWAYS perform an authentication check (e.g., using getCurrentUser) inside every Server Action that requires a logged-in user. Do not rely solely on UI-level restrictions.

These actions require the user to be authenticated.

Automatic Session Handling

NextAuth.js handles session management for Server Actions called from the client automatically. The getCurrentUser helper will correctly identify the logged-in user.

Example Backend Action (app/actions/profile.ts):

app/actions/profile.ts
"use server";
 
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUser } from "@/lib/session";
import { revalidatePath } from "next/cache";
 
const profileSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
});
 
type ProfileFormState = {
  status: "error" | "success";
  message: string;
  errors?: {
    name?: string[];
  };
};
 
export async function updateProfile(
  prevState: ProfileFormState,
  formData: FormData
): Promise<ProfileFormState> {
  const user = await getCurrentUser();
  if (!user) {
    return { status: "error", message: "Unauthorized" };
  }
 
  const validatedFields = profileSchema.safeParse({
    name: formData.get("name"),
  });
 
  if (!validatedFields.success) {
    return {
      status: "error",
      message: "Invalid input",
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }
 
  try {
    await prisma.user.update({
      where: { id: user.id },
      data: {
        name: validatedFields.data.name,
      },
    });
    revalidatePath("/profile"); // Revalidate relevant pages
    return { status: "success", message: "Profile updated successfully!" };
  } catch (error) {
    console.error("Profile update error:", error);
    return { status: "error", message: "Failed to update profile." };
  }
}

Example Frontend Invocation (useActionState):

components/profile-form.tsx
"use client";
 
import { useActionState } from "react";
import { updateProfile } from "@/app/actions/profile";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
 
const initialState = {
  status: "" as "error" | "success", // Type assertion needed
  message: "",
};
 
function ProfileForm({ currentName }: { currentName: string }) {
  const [state, formAction, isPending] = useActionState(
    updateProfile,
    initialState
  );
 
  // Show toast on success/error messages from state
  if (state.message) {
    if (state.status === "success") {
      toast.success(state.message);
      // Optionally reset state message here if needed
    } else if (state.status === "error") {
      toast.error(state.message);
      // Optionally reset state message here if needed
    }
  }
 
  return (
    <form action={formAction} className="space-y-4">
      <div>
        <Input name="name" defaultValue={currentName} required minLength={2} />
        {state?.errors?.name && (
          <p className="text-sm text-red-500">{state.errors.name[0]}</p>
        )}
      </div>
      <Button type="submit" disabled={isPending}>
        {isPending ? "Updating..." : "Update Profile"}
      </Button>
    </form>
  );
}
 
export default ProfileForm;

Always Check Authorization!

Similar to authentication, even if your UI prevents users without access from reaching a component, the Server Action itself can be triggered directly. It is critical to ALWAYS perform authorization checks (e.g., verifying user.hasAccess or specific permissions) inside every Server Action that requires specific access rights. Do not rely solely on UI restrictions.

These actions require authentication AND specific access rights (e.g., an active subscription).

Checking Access Efficiently

Ensure your getCurrentUser helper method return currentUser which already has hasAccess field.

Example Backend Action (app/actions/premium.ts):

app/actions/premium.ts
"use server";
 
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUser } from "@/lib/session";
import { revalidatePath } from "next/cache";
 
const premiumResourceSchema = z.object({
  title: z.string().min(5, "Title must be at least 5 characters"),
});
 
type PremiumFormState = {
  status: "error" | "success";
  message: string;
  errors?: {
    title?: string[];
  };
};
 
export async function createPremiumResource(
  prevState: PremiumFormState,
  formData: FormData
): Promise<PremiumFormState> {
  const user = await getCurrentUser();
 
  // 1. Check Authentication
  if (!user) {
    return { status: "error", message: "Unauthorized" };
  }
 
  // 2. Check Access
  if (!user.hasAccess) {
    // Assuming 'hasAccess' is in the session
    return { status: "error", message: "Forbidden: Premium access required." };
  }
 
  // 3. Validate Input
  const validatedFields = premiumResourceSchema.safeParse({
    title: formData.get("title"),
  });
 
  if (!validatedFields.success) {
    return {
      status: "error",
      message: "Invalid input",
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }
 
  // 4. Perform Action
  try {
    await prisma.premiumResource.create({
      data: {
        title: validatedFields.data.title,
        userId: user.id, // Associate with user
      },
    });
    revalidatePath("/premium"); // Revalidate relevant pages
    return {
      status: "success",
      message: "Premium resource created successfully!",
    };
  } catch (error) {
    console.error("Premium resource creation error:", error);
    return { status: "error", message: "Failed to create premium resource." };
  }
}

Example Frontend Invocation (useActionState):

components/premium-form.tsx
"use client";
 
import { useActionState } from "react";
import { createPremiumResource } from "@/app/actions/premium";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
 
const initialState = {
  status: "" as "error" | "success",
  message: "",
};
 
function PremiumForm() {
  const [state, formAction, isPending] = useActionState(
    createPremiumResource,
    initialState
  );
 
  // Show toast on success/error messages from state
  if (state.message) {
    if (state.status === "success") {
      toast.success(state.message);
    } else if (state.status === "error") {
      toast.error(state.message);
    }
  }
 
  return (
    <form action={formAction} className="space-y-4">
      <div>
        <Input
          name="title"
          placeholder="Premium Resource Title"
          required
          minLength={5}
        />
        {state?.errors?.title && (
          <p className="text-sm text-red-500">{state.errors.title[0]}</p>
        )}
      </div>
      <Button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create Premium Resource"}
      </Button>
    </form>
  );
}
 
export default PremiumForm;

On this page