Server actions
Every exported server action in lib/*-actions.ts and lib/actions.ts, with its client, input schema and behavior.
Launch Now uses next-safe-action for mutations and most data fetching. Every action file starts with "use server" and exports actions built from one of the clients in lib/safe-action.ts. Inputs are validated with Zod before your code runs.
Action clients
lib/safe-action.ts defines three clients. Some action files add a local admin client on top of authAction.
| Client | Defined in | Guarantees | Context |
|---|---|---|---|
action | lib/safe-action.ts | None: public, no session required | none |
authAction | lib/safe-action.ts | A signed-in user (getUser()), otherwise throws You need to be connected ! | ctx.user |
nonDemoAction | lib/safe-action.ts | Same as authAction, and refuses the shared demo account (DEMO_EMAIL) with This action is disabled on the demo account | ctx.user |
adminAction | local to lib/notification-actions.ts and lib/newsletter-actions.ts | authAction plus ctx.user.role === "admin" | ctx.user |
Many admin actions in lib/admin-actions.ts and lib/feedback-actions.ts use authAction and check ctx.user.role !== "admin" inside the handler instead.
export const action = createSafeActionClient({
handleServerError(error) {
if (error instanceof ActionError) {
return error.message
}
console.error("Unexpected error:", error)
return "Unexpected error occured"
},
})
export const authAction = action.use(async ({ next }) => {
const user = await getUser()
if (!user) {
throw new ActionError("You need to be connected !")
}
return next({ ctx: { user } })
})Errors
Throw ActionError from lib/safe-action.ts to send a message to the client. Its message is returned as result.serverError. Any other error is logged on the server and replaced with a generic message, so internal details never leak.
Calling an action
From a Client Component, use the useAction hook from next-safe-action/hooks:
import { useAction } from "next-safe-action/hooks"
import { createFeedback } from "@/lib/feedback-actions"
// ...
const { execute, status } = useAction(createFeedback, {
onSuccess: () => {
toast.add({ title: "Thanks for your feedback!", type: "success" })
},
onError: () => {
toast.add({ title: "Failed to submit feedback", type: "error" })
},
})From a Server Component, await the action directly and read result.data.
Organization checks
Organization actions call requireMembership(organizationId, userId, allowedRoles?) from lib/organization-server.ts. It throws You are not a member of this organization when the user has no membership row, and You don't have permission to do this when their role is not in allowedRoles. In the tables below, "Roles" lists the allowedRoles passed; "any member" means no role restriction.
lib/actions.ts
| Action | Client | Input | Description |
|---|---|---|---|
getProfile | authAction | none | Returns ctx.user. |
lib/account-actions.ts
Account settings for the signed-in user. These actions call Better Auth's server API (auth.api) with the request headers.
| Action | Client | Input | Description |
|---|---|---|---|
updateProfileName | authAction | name: string, 1–32 chars | Updates the name through auth.api.updateUser. Revalidates /account. |
updateProfileImage | authAction | image: string | Updates the avatar URL through auth.api.updateUser. Revalidates /account. |
setPassword | nonDemoAction | password: string, min 8 chars | Sets a password with auth.api.setPassword, emails a confirmation to the user, and revalidates /account/security. |
setApiKeyEnabled | authAction | keyId: string; enabled: boolean | Enables or disables an API key with auth.api.updateApiKey. Revalidates /account/keys. |
lib/onboarding-actions.ts
| Action | Client | Input | Description |
|---|---|---|---|
completeOnboarding | authAction (wrapped) | Optional object: name (trimmed, 1–32 chars), fileBase64, fileName, fileType, all optional | Uploads the avatar if fileBase64 is set (validated by magic bytes, stored under avatars), updates name and image through auth.api.updateUser, then sets user.onboardingCompleted to true. Revalidates the root layout. Returns { success: true }. |
completeOnboarding is a plain async function that forwards to the inner safe action, so the form can call completeOnboarding() with no argument.
lib/demo-actions.ts
| Action | Client | Input | Description |
|---|---|---|---|
signInAsDemo | none (plain server function) | callbackURL?: string | Signs in with DEMO_EMAIL and DEMO_PASSWORD through auth.api.signInEmail, then redirects to the sanitized callbackURL or /dashboard. Returns { error } when the demo account is not configured or sign-in fails. |
Good to know: pnpm reset-project moves lib/demo-actions.ts to examples/.
lib/feedback-actions.ts
Feedback submitted from the in-app feedback dialog. See Admin.
| Action | Client | Input | Description |
|---|---|---|---|
createFeedback | authAction | message: string, 1–2000 chars; category?: string | Inserts a feedback row with status new, copying the user's email and name. Sends an admin.feedback_received notification to platform admins. |
listFeedback | authAction, admin only | none | Returns all feedback, newest first. |
updateFeedbackStatus | authAction, admin only | feedbackId: string; status: new, read, acknowledged or resolved | Updates the status. Returns { feedbackId, status }. |
deleteFeedback | authAction, admin only | feedbackId: string | Deletes one feedback. Returns { feedbackId }. |
deleteFeedbackBulk | authAction, admin only | feedbackIds: string array, min 1 | Deletes several feedbacks. Returns { count }. |
lib/admin-actions.ts
Platform administration. Every action requires ctx.user.role === "admin" and throws Not authorized otherwise. Actions marked "not self" also throw when userId is the caller's own ID. See Admin.
Users
| Action | Input | Description |
|---|---|---|
listUsers | none | Returns all users (safe columns only, no secrets), newest first, each with its most recent session as lastSession. |
getUserById | userId: string | Returns { user, accounts, sessions, memberships, subscription, profile } or null. Accounts exclude passwords and tokens. Sessions merge active session rows and ended session_log rows, newest first. subscription prefers one in active, trialing or past_due status. |
updateUser | userId: string; name?: 1–32 chars; email?: email | Updates name and/or email directly in the database. No-op if both are empty. |
deleteUser | userId: string | Deletes the user (not self). Related rows cascade. |
revokeUserSessions | userId: string | Deletes all sessions of the user. Returns { revokedCount }. |
revokeUserSession | userId: string; sessionId: string | Deletes one session. Throws This session has already ended if nothing was deleted. |
toggleBanStatus | userId: string; ban: boolean | Bans (deletes all sessions, sets banned and banReason: "Banned by admin") or unbans the user (not self). |
toggleAdminRole | userId: string; makeAdmin: boolean | Sets role to admin or user (not self). |
updateUserAvatar | userId: string; fileBase64?; fileName?; fileType?; remove?: boolean | Removes the avatar when remove is true, otherwise uploads fileBase64 and sets it as user.image. Returns { url }. |
uploadImage | fileBase64, fileName, fileType: strings | Uploads an image to storage and returns { url } without changing any row. |
revalidateAdmin | none | Revalidates /admin/users. |
Uploads are capped by MAX_IMAGE_UPLOAD_BYTES from lib/storage.ts. The file type comes from the file's magic bytes, not from fileName or fileType.
Organizations
| Action | Input | Description |
|---|---|---|
listOrganizations | none | Returns all organizations, newest first, each with memberCount and subscription (or null). |
getOrganizationAdmin | slug: string | Returns { organization, members, subscription } or null. Members include the user's name, email and image. |
checkOrganizationSlugAdmin | slug: string; organizationId?: string | Returns { available, reason? }. Validates the slug format and checks uniqueness, ignoring organizationId so an organization keeps its own slug. |
updateOrganizationAdmin | organizationId: string; name?: trimmed, 1–64 chars; slug?: slug | Renames the organization and/or changes its slug. Returns { slug }. |
deleteOrganizationAdmin | organizationId: string | Refuses when a subscription is active, trialing or past_due. Otherwise deletes subscriptions, members, invitations and the organization in a transaction. |
Slugs are validated by slugSchema in lib/slug.ts: 2–48 characters, lowercase letters, numbers and single hyphens, and not a reserved word.
lib/organization-actions.ts
Actions for organization members. Membership and roles are checked with requireMembership. Most mutations call revalidateOrganization, which revalidates every page under /orgs/[slug].
| Action | Client | Input | Roles | Description |
|---|---|---|---|---|
getOrganizationBySlug | authAction | slug: string | any member | Returns { organization, membership }. Throws Organization not found. |
checkSlugAvailability | authAction | slug: string | none | Returns { available, reason? } after format and uniqueness checks. |
updateOrganizationName | authAction | organizationId; name: trimmed, 1–64 chars | owner, admin | Renames the organization. |
updateOrganizationSlug | authAction | organizationId; slug: slug | owner | Changes the slug if not taken. Returns { slug }. |
updateOrganizationLogo | authAction | organizationId; logo: string | owner, admin | Sets the logo URL. An empty string clears it. |
deleteOrganization | nonDemoAction | organizationId; confirmName: string | owner | Requires confirmName to equal the organization name and no active or trialing subscription. Clears activeOrganizationId on sessions, deletes subscriptions and the organization (members and invitations cascade). |
inviteMember | authAction | organizationId; email: email (lowercased); role: member or admin, default member | owner, admin | Refuses existing members, then calls auth.api.createInvitation, which sends the invitation email. |
cancelInvitation | authAction | invitationId: string | owner, admin | Sets the invitation status to canceled. |
resendInvitation | authAction | invitationId: string | owner, admin | Resends a pending invitation through auth.api.createInvitation. |
removeMember | authAction | memberId: string | owner, admin | Removes a member. You cannot remove yourself, and admins can only remove members. Notifies the removed user (org.member_removed). |
updateMemberRole | authAction | memberId: string; role: member or admin | owner | Changes a member's role. The owner's role and your own role cannot change. Notifies the member (org.role_changed) when the role differs. |
leaveOrganization | authAction | organizationId: string | any member except owner | Deletes your membership and clears it as your active organization. Notifies owners and admins (org.member_left). |
Better Auth API errors are converted to ActionError, so their message reaches the client.
lib/notification-actions.ts
In-app notifications, preferences and announcements. category values come from NOTIFICATION_CATEGORIES in lib/notifications/events.ts: organization, billing, security, admin and announcements. See Notifications.
| Action | Client | Input | Description |
|---|---|---|---|
fetchNotifications | authAction | unreadOnly?: boolean; archived?: boolean; categories?: category array; before?: ISO datetime; limit?: integer 1–50 | Returns { items, unread }: a page of notifications visible to the user and the unread count. |
fetchNotification | authAction | id: string | Returns one notification, or throws Notification not found. |
markNotificationsRead | authAction | ids?: up to 100 strings; all?: boolean; action?: read, unread, archive or unarchive | Updates the user's receipts. Returns { unread }. |
fetchNotificationPreferences | authAction | none | Returns the user's preference per category. Admin-only categories are hidden from non-admins. |
updateNotificationPreference | authAction | category; inApp: boolean; email: boolean | Saves the preference for one category. |
sendBroadcast | adminAction | title: trimmed, 1–120 chars; body?: max 1000; link?: in-app path starting with / or empty; audience: all, admins, organization or user; organizationId?; userId?; email: boolean, default false | Sends an announcement notification to the audience. organizationId is required for organization and userId for user. Returns { id }. |
fetchBroadcasts | adminAction | none | Returns the announcements sent so far (listSentBroadcasts). |
sendOrganizationAnnouncement | authAction | organizationId; title: trimmed, 1–120 chars; body?: max 1000; email: boolean, default false | Org owner or admin only. Sends an announcement to every member of the organization. |
lib/newsletter-actions.ts
Newsletter subscriptions and sends, backed by Resend. The newsletter is disabled when newsletterEnabled() returns false. See Newsletter.
| Action | Client | Input | Description |
|---|---|---|---|
subscribeToNewsletter | action (public) | email: email (trimmed, lowercased) | Sends a double opt-in confirmation email linking to /api/newsletter/confirm, unless the address is already subscribed. At most one email per address per minute per server instance. Always returns the same answer ({ ok, disabled }) so it does not reveal whether an address is subscribed. |
getMyNewsletterStatus | authAction | none | Returns { enabled, subscribed } for the signed-in user's email. |
setMyNewsletterSubscription | authAction | subscribed: boolean | Subscribes or unsubscribes the user's email directly (no double opt-in). Returns { subscribed }. |
sendNewsletter | adminAction | subject: trimmed, 1–200 chars; content: trimmed, 1–50,000 chars; sendAt?: ISO 8601 datetime with offset | Renders the content to HTML, creates a Resend broadcast for RESEND_AUDIENCE_ID and sends it now or at sendAt (must be in the future). Returns { id, scheduled }. |
listNewsletterBroadcasts | adminAction | none | Returns { enabled, broadcasts }: the Resend broadcasts for the configured audience, with id, name, status, createdAt, scheduledAt and sentAt. |
The file also exports the NewsletterBroadcast type.
Example resources
These actions back the example CRUD pages. pnpm reset-project moves all three files to examples/. See Scripts.
lib/categories-actions.ts
User-scoped. Every action only touches the caller's own rows.
| Action | Client | Input | Description |
|---|---|---|---|
listCategories | authAction | none | Returns the user's categories, newest first. |
getCategoryById | authAction | categoryId: string | Returns the category, or null if missing or owned by someone else. |
createCategory | authAction | name: 1–100 chars; description?: max 500 | Creates a category. Revalidates /categories. |
updateCategory | authAction | id; name?: 1–100 chars; description?: max 500 | Updates a category you own. |
deleteCategory | authAction | id: string | Deletes a category you own. |
lib/projects-actions.ts
User-scoped. Rows are always filtered by userId.
| Action | Client | Input | Description |
|---|---|---|---|
listProjects | authAction | none | Returns the user's projects with categoryName, newest first. |
getProjectById | authAction | id: string | Returns one project, or throws Project not found. |
createProject | authAction | name: 1–100 chars; status?; budget?: number; dueDate?; categoryId? | Creates a project. status defaults to planning. The category must belong to the user. |
updateProject | authAction | id; name?; status?; budget?, dueDate?, categoryId? (each nullable) | Updates only the fields provided. |
deleteProject | authAction | id: string | Deletes a project, or throws Project not found. |
listCategoriesForSelect | authAction | none | Returns { id, name } for each of the user's categories, for select inputs. |
lib/org-project-actions.ts
Organization-scoped, generated by the resource-kit (pnpm resource:generate). The insert and update schemas are built from the fields in lib/org-project-config.ts: name (required), description, status (planned, active, paused or done) and dueDate.
| Action | Client | Input | Roles | Description |
|---|---|---|---|---|
listOrgProjects | authAction | organizationId | any member | Returns the organization's projects, newest first. |
getOrgProject | authAction | organizationId; id | any member | Returns one project or null. |
createOrgProject | authAction | organizationId plus the resource fields | any member | Creates a project owned by the organization, with the caller as userId. |
updateOrgProject | authAction | organizationId; id; optional resource fields | any member | Updates a project, or throws Project not found. |
deleteOrgProject | nonDemoAction | organizationId; id | owner, admin | Deletes one project. |
deleteOrgProjects | nonDemoAction | organizationId; ids: 1–100 strings | owner, admin | Deletes several projects. Returns { count }. |
Resources you generate with pnpm resource:generate get a lib/<name>-actions.ts file with the same shape.
Adding an action
Create or extend a file in lib/, start it with "use server", and pick the narrowest client:
"use server"
import { ActionError, authAction } from "@/lib/safe-action"
import { z } from "zod"
const renameThing = authAction
.inputSchema(z.object({ id: z.string(), name: z.string().min(1) }))
.action(async ({ parsedInput: { id, name }, ctx: { user } }) => {
// ... check that `user` owns `id`, then update it
if (!id) throw new ActionError("Not found")
return { id, name }
})
export { renameThing }Use nonDemoAction for destructive actions that the shared demo account must not run, and check ctx.user.role === "admin" for platform-admin actions.