Better Auth
next-safe-action has a first-party adapter for Better Auth that provides a betterAuth() function. It fetches the session, blocks unauthenticated requests, and injects fully-typed { user, session } data into the action context, including any fields added by Better Auth plugins.
Installation
npm install next-safe-action better-auth @next-safe-action/adapter-better-authQuick start
Set up Better Auth
Create your Better Auth server instance. If your actions need to set cookies (e.g. signInEmail, signUpEmail), add the nextCookies() plugin:
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
export const auth = betterAuth({
// ...your config (database, plugins, etc.)
plugins: [
// ...other plugins
nextCookies(), // must be the last plugin in the array
],
});The nextCookies() plugin is only required if you call Better Auth functions that set cookies (like signInEmail or signUpEmail) from Server Actions. It uses the Next.js cookies() helper to set cookies when a Set-Cookie header is present in the response. If your actions only read the session, you can skip it. Refer to the Better Auth documentation for more details.
Create an authenticated action client
Use betterAuth() to add authentication to your action client:
import { createSafeActionClient } from "next-safe-action";
import { betterAuth } from "@next-safe-action/adapter-better-auth";
import { auth } from "./auth";
// Public action client (no auth required)
export const actionClient = createSafeActionClient();
// Authenticated action client
export const authClient = actionClient.use(betterAuth(auth));Use it in your actions
Actions defined with authClient have typed access to ctx.auth.user and ctx.auth.session:
"use server";
import { z } from "zod";
import { authClient } from "@/lib/safe-action";
export const updateProfile = authClient
.inputSchema(z.object({ name: z.string().min(1) }))
.action(async ({ parsedInput, ctx }) => {
// ctx.auth.user and ctx.auth.session are fully typed,
// including fields from Better Auth plugins
const userId = ctx.auth.user.id;
await db.user.update({
where: { id: userId },
data: { name: parsedInput.name },
});
return { success: true };
});How it works
betterAuth() creates a pre-validation middleware for the safe action client's .use() chain:
- Fetches the session by calling
auth.api.getSession({ headers: await headers() })using the request headers fromnext/headers - Blocks unauthenticated requests by calling
unauthorized()fromnext/navigationwhen no session exists - Injects typed context by passing
{ auth: { user, session } }tonext(), merging it into the action context
The context is namespaced under auth to avoid collisions with other middleware that might add their own context properties.
Type inference
The middleware infers the exact user and session types from your Better Auth instance, including any fields added by plugins. For example, if you use the organization plugin, ctx.auth.session will include activeOrganizationId. No manual type annotations are needed.
unauthorized() and auth interrupts
The default behavior uses unauthorized() from next/navigation, which requires the authInterrupts experimental flag in your Next.js configuration:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
authInterrupts: true,
},
};
export default nextConfig;When enabled, unauthorized() triggers a 401 response that renders your nearest unauthorized.tsx boundary. See the framework errors page for more details on how next-safe-action handles navigation functions.
Custom authorization with authorize
The default flow works for most cases, but you can customize the authorization logic by passing an authorize callback. The session is pre-fetched and passed to the callback, so common customizations like role checks don't need to re-fetch:
Role-based access
import { unauthorized } from "next/navigation";
import { betterAuth } from "@next-safe-action/adapter-better-auth";
import { auth } from "./auth";
export const adminClient = actionClient.use(
betterAuth(auth, {
authorize: ({ authData, next }) => {
if (!authData || authData.user.role !== "admin") {
unauthorized();
}
return next({ ctx: { auth: authData } });
},
})
);Redirect instead of 401
import { redirect } from "next/navigation";
import { betterAuth } from "@next-safe-action/adapter-better-auth";
import { auth } from "./auth";
export const authClient = actionClient.use(
betterAuth(auth, {
authorize: ({ authData, next }) => {
if (!authData) {
redirect("/login");
}
return next({ ctx: { auth: authData } });
},
})
);authorize callback parameters
Prop
Type
Server Action cookies
When calling Better Auth functions that mutate cookies from Server Actions (e.g. signInEmail, signUpEmail), cookies won't be set by default. This is because Server Actions need to use the Next.js cookies() helper to set cookies.
To handle this automatically, add the nextCookies() plugin to your Better Auth server instance:
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
export const auth = betterAuth({
// ...your config
plugins: [
// ...other plugins
nextCookies(), // must be the last plugin in the array
],
});With this plugin, any Better Auth function called from a Server Action that returns a Set-Cookie header will automatically set the cookie via Next.js:
"use server";
import { z } from "zod";
import { authClient } from "@/lib/safe-action";
export const signIn = authClient
.inputSchema(z.object({ email: z.string().email(), password: z.string() }))
.action(async ({ parsedInput, ctx }) => {
// This works because nextCookies() handles cookie setting
await ctx.auth.session; // session is already available from the middleware
// Or call other Better Auth functions that set cookies:
// await auth.api.signInEmail({ body: parsedInput });
});Package entry points
| Entry point | Exports | Environment |
|---|---|---|
@next-safe-action/adapter-better-auth | betterAuth | Server |
Exported types
| Type | Description |
|---|---|
BetterAuthContext<Options> | The context shape added by the middleware: { auth: { user, session } } |
AuthorizeFn<Options, NextCtx> | The authorize callback signature |
BetterAuthOpts<Options, NextCtx> | The options object type for betterAuth |
See also
- Middleware guide: how middleware and context chaining work
- Framework errors: handling
unauthorized(),redirect(), and other navigation functions - Standalone middleware:
createMiddleware()for reusable middleware - TanStack Query integration: sibling adapter for TanStack Query mutations
- React Hook Form integration: sibling adapter for react-hook-form