Launch NowDocs

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.

ClientDefined inGuaranteesContext
actionlib/safe-action.tsNone: public, no session requirednone
authActionlib/safe-action.tsA signed-in user (getUser()), otherwise throws You need to be connected !ctx.user
nonDemoActionlib/safe-action.tsSame as authAction, and refuses the shared demo account (DEMO_EMAIL) with This action is disabled on the demo accountctx.user
adminActionlocal to lib/notification-actions.ts and lib/newsletter-actions.tsauthAction 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.

lib/safe-action.ts
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:

components/feedback-dialog.tsx
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

ActionClientInputDescription
getProfileauthActionnoneReturns 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.

ActionClientInputDescription
updateProfileNameauthActionname: string, 1–32 charsUpdates the name through auth.api.updateUser. Revalidates /account.
updateProfileImageauthActionimage: stringUpdates the avatar URL through auth.api.updateUser. Revalidates /account.
setPasswordnonDemoActionpassword: string, min 8 charsSets a password with auth.api.setPassword, emails a confirmation to the user, and revalidates /account/security.
setApiKeyEnabledauthActionkeyId: string; enabled: booleanEnables or disables an API key with auth.api.updateApiKey. Revalidates /account/keys.

lib/onboarding-actions.ts

ActionClientInputDescription
completeOnboardingauthAction (wrapped)Optional object: name (trimmed, 1–32 chars), fileBase64, fileName, fileType, all optionalUploads 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

ActionClientInputDescription
signInAsDemonone (plain server function)callbackURL?: stringSigns 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.

ActionClientInputDescription
createFeedbackauthActionmessage: string, 1–2000 chars; category?: stringInserts a feedback row with status new, copying the user's email and name. Sends an admin.feedback_received notification to platform admins.
listFeedbackauthAction, admin onlynoneReturns all feedback, newest first.
updateFeedbackStatusauthAction, admin onlyfeedbackId: string; status: new, read, acknowledged or resolvedUpdates the status. Returns { feedbackId, status }.
deleteFeedbackauthAction, admin onlyfeedbackId: stringDeletes one feedback. Returns { feedbackId }.
deleteFeedbackBulkauthAction, admin onlyfeedbackIds: string array, min 1Deletes 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

ActionInputDescription
listUsersnoneReturns all users (safe columns only, no secrets), newest first, each with its most recent session as lastSession.
getUserByIduserId: stringReturns { 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.
updateUseruserId: string; name?: 1–32 chars; email?: emailUpdates name and/or email directly in the database. No-op if both are empty.
deleteUseruserId: stringDeletes the user (not self). Related rows cascade.
revokeUserSessionsuserId: stringDeletes all sessions of the user. Returns { revokedCount }.
revokeUserSessionuserId: string; sessionId: stringDeletes one session. Throws This session has already ended if nothing was deleted.
toggleBanStatususerId: string; ban: booleanBans (deletes all sessions, sets banned and banReason: "Banned by admin") or unbans the user (not self).
toggleAdminRoleuserId: string; makeAdmin: booleanSets role to admin or user (not self).
updateUserAvataruserId: string; fileBase64?; fileName?; fileType?; remove?: booleanRemoves the avatar when remove is true, otherwise uploads fileBase64 and sets it as user.image. Returns { url }.
uploadImagefileBase64, fileName, fileType: stringsUploads an image to storage and returns { url } without changing any row.
revalidateAdminnoneRevalidates /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

ActionInputDescription
listOrganizationsnoneReturns all organizations, newest first, each with memberCount and subscription (or null).
getOrganizationAdminslug: stringReturns { organization, members, subscription } or null. Members include the user's name, email and image.
checkOrganizationSlugAdminslug: string; organizationId?: stringReturns { available, reason? }. Validates the slug format and checks uniqueness, ignoring organizationId so an organization keeps its own slug.
updateOrganizationAdminorganizationId: string; name?: trimmed, 1–64 chars; slug?: slugRenames the organization and/or changes its slug. Returns { slug }.
deleteOrganizationAdminorganizationId: stringRefuses 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].

ActionClientInputRolesDescription
getOrganizationBySlugauthActionslug: stringany memberReturns { organization, membership }. Throws Organization not found.
checkSlugAvailabilityauthActionslug: stringnoneReturns { available, reason? } after format and uniqueness checks.
updateOrganizationNameauthActionorganizationId; name: trimmed, 1–64 charsowner, adminRenames the organization.
updateOrganizationSlugauthActionorganizationId; slug: slugownerChanges the slug if not taken. Returns { slug }.
updateOrganizationLogoauthActionorganizationId; logo: stringowner, adminSets the logo URL. An empty string clears it.
deleteOrganizationnonDemoActionorganizationId; confirmName: stringownerRequires 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).
inviteMemberauthActionorganizationId; email: email (lowercased); role: member or admin, default memberowner, adminRefuses existing members, then calls auth.api.createInvitation, which sends the invitation email.
cancelInvitationauthActioninvitationId: stringowner, adminSets the invitation status to canceled.
resendInvitationauthActioninvitationId: stringowner, adminResends a pending invitation through auth.api.createInvitation.
removeMemberauthActionmemberId: stringowner, adminRemoves a member. You cannot remove yourself, and admins can only remove members. Notifies the removed user (org.member_removed).
updateMemberRoleauthActionmemberId: string; role: member or adminownerChanges a member's role. The owner's role and your own role cannot change. Notifies the member (org.role_changed) when the role differs.
leaveOrganizationauthActionorganizationId: stringany member except ownerDeletes 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.

ActionClientInputDescription
fetchNotificationsauthActionunreadOnly?: boolean; archived?: boolean; categories?: category array; before?: ISO datetime; limit?: integer 1–50Returns { items, unread }: a page of notifications visible to the user and the unread count.
fetchNotificationauthActionid: stringReturns one notification, or throws Notification not found.
markNotificationsReadauthActionids?: up to 100 strings; all?: boolean; action?: read, unread, archive or unarchiveUpdates the user's receipts. Returns { unread }.
fetchNotificationPreferencesauthActionnoneReturns the user's preference per category. Admin-only categories are hidden from non-admins.
updateNotificationPreferenceauthActioncategory; inApp: boolean; email: booleanSaves the preference for one category.
sendBroadcastadminActiontitle: 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 falseSends an announcement notification to the audience. organizationId is required for organization and userId for user. Returns { id }.
fetchBroadcastsadminActionnoneReturns the announcements sent so far (listSentBroadcasts).
sendOrganizationAnnouncementauthActionorganizationId; title: trimmed, 1–120 chars; body?: max 1000; email: boolean, default falseOrg 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.

ActionClientInputDescription
subscribeToNewsletteraction (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.
getMyNewsletterStatusauthActionnoneReturns { enabled, subscribed } for the signed-in user's email.
setMyNewsletterSubscriptionauthActionsubscribed: booleanSubscribes or unsubscribes the user's email directly (no double opt-in). Returns { subscribed }.
sendNewsletteradminActionsubject: trimmed, 1–200 chars; content: trimmed, 1–50,000 chars; sendAt?: ISO 8601 datetime with offsetRenders 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 }.
listNewsletterBroadcastsadminActionnoneReturns { 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.

ActionClientInputDescription
listCategoriesauthActionnoneReturns the user's categories, newest first.
getCategoryByIdauthActioncategoryId: stringReturns the category, or null if missing or owned by someone else.
createCategoryauthActionname: 1–100 chars; description?: max 500Creates a category. Revalidates /categories.
updateCategoryauthActionid; name?: 1–100 chars; description?: max 500Updates a category you own.
deleteCategoryauthActionid: stringDeletes a category you own.

lib/projects-actions.ts

User-scoped. Rows are always filtered by userId.

ActionClientInputDescription
listProjectsauthActionnoneReturns the user's projects with categoryName, newest first.
getProjectByIdauthActionid: stringReturns one project, or throws Project not found.
createProjectauthActionname: 1–100 chars; status?; budget?: number; dueDate?; categoryId?Creates a project. status defaults to planning. The category must belong to the user.
updateProjectauthActionid; name?; status?; budget?, dueDate?, categoryId? (each nullable)Updates only the fields provided.
deleteProjectauthActionid: stringDeletes a project, or throws Project not found.
listCategoriesForSelectauthActionnoneReturns { 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.

ActionClientInputRolesDescription
listOrgProjectsauthActionorganizationIdany memberReturns the organization's projects, newest first.
getOrgProjectauthActionorganizationId; idany memberReturns one project or null.
createOrgProjectauthActionorganizationId plus the resource fieldsany memberCreates a project owned by the organization, with the caller as userId.
updateOrgProjectauthActionorganizationId; id; optional resource fieldsany memberUpdates a project, or throws Project not found.
deleteOrgProjectnonDemoActionorganizationId; idowner, adminDeletes one project.
deleteOrgProjectsnonDemoActionorganizationId; ids: 1–100 stringsowner, adminDeletes 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.