nextjs-16-server-action-requirements
Next.js 16 Server Action Requirements
Problem
Next.js 16 enforces strict rules for files with the "use server" directive. Violations cause
build failures with cryptic error messages. Common issues include exporting non-async functions,
constants, or using static imports of next/headers.
Context / Trigger Conditions
- Build Error:
A 'use server' file can only export async functions, found object - Build Error: Static imports of
next/headersforce modules into dynamic rendering - TypeScript Error: Helper functions in server actions fail type checking
- Next.js Version: 16.x (these rules are stricter than Next.js 15)
Solution
Rule 1: All Exported Functions Must Be Async
Every exported function in a "use server" file must be async, even if it doesn't perform
async operations.
"use server";
// ❌ WRONG - Synchronous exported function
export function error(message: string): ActionError {
return { success: false, error: message };
}
// ✅ CORRECT - Async function
export async function error(message: string): Promise<ActionError> {
return { success: false, error: message };
}
Applies to all helpers:
error(),success()- result buildersvalidateInput()- validation helpershandleActionError()- error handlers- Any utility function exported from the file
Rule 2: No Constant or Type Exports
You cannot export constants, enums, types, or objects from "use server" files.
"use server";
// ❌ WRONG - Exporting constants
export const AUDIT_ACTIONS = {
PROFILE_UPDATE: "PROFILE_UPDATE",
PASSWORD_CHANGE: "PASSWORD_CHANGE",
};
export enum Status {
PENDING = "PENDING",
COMPLETE = "COMPLETE",
}
Solution: Move constants to a separate file without "use server":
// lib/constants/audit-actions.ts (no "use server")
export const AUDIT_ACTIONS = {
PROFILE_UPDATE: "PROFILE_UPDATE",
PASSWORD_CHANGE: "PASSWORD_CHANGE",
} as const;
export type AuditAction = typeof AUDIT_ACTIONS[keyof typeof AUDIT_ACTIONS];
// lib/actions/my-action.ts
"use server";
import { AUDIT_ACTIONS } from "@/lib/constants/audit-actions";
export async function myAction() {
// Use imported constants
await createLog(AUDIT_ACTIONS.PROFILE_UPDATE);
}
Rule 3: Use Dynamic Imports for next/headers
Static imports of next/headers make the entire module opt into dynamic rendering immediately.
Use dynamic imports to defer this decision.
"use server";
// ❌ WRONG - Static import forces dynamic rendering
import { headers } from "next/headers";
export async function myAction() {
const h = await headers();
// ...
}
"use server";
// ✅ CORRECT - Dynamic import defers dynamic rendering decision
export async function myAction() {
const { headers } = await import("next/headers");
const h = await headers();
// ...
}
Why this matters:
- Static imports force the entire module into dynamic rendering at import time
- Dynamic imports defer this until the function actually runs
- Gives better control over when routes become dynamic
- Prevents unintended side effects when server action modules are imported elsewhere
Note: Using headers() still forces dynamic rendering for that specific request, but the
dynamic import pattern gives you more control over the scope.
Rule 4: Always Await Helper Functions
Since all helper functions must be async, you must await them:
"use server";
export async function myAction(data: unknown): Promise<ActionResult> {
const auth = await getAuthContext();
// ❌ WRONG - Missing await
if (!auth.authenticated) return error("Unauthorized");
// ✅ CORRECT - Awaiting async helper
if (!auth.authenticated) return await error("Unauthorized");
const validation = await validateInput(schema, data);
if (!validation.valid) return validation;
// ...
}
Common Patterns
Server Action Structure (Recommended)
"use server";
import { getAuthContext, handleActionError, validateInput, type ActionResult } from "./utils";
import { mySchema } from "database/schemas";
export async function myAction(data: unknown): Promise<ActionResult> {
// 1. Get authenticated context (handles dynamic imports internally)
const auth = await getAuthContext();
if (!auth.authenticated) return auth;
// 2. Validate input
const validation = await validateInput(mySchema, data);
if (!validation.valid) return validation;
// 3. Perform operation with error handling
try {
const result = await auth.db.myModel.create({
data: validation.data,
});
return { success: true, data: result };
} catch (err) {
return await handleActionError(err, "my operation");
}
}
Helper Functions in utils.ts
"use server";
// No static import of next/headers
import { z } from "zod";
import { getUser } from "@/lib/utils/auth";
import { db as dbClient, User } from "database";
export type ActionResult = { success: true } | { success: false; error: string };
// All helpers must be async and return Promise
export async function error(message: string): Promise<{ success: false; error: string }> {
return { success: false, error: message };
}
export async function success(): Promise<{ success: true }> {
return { success: true };
}
export async function validateInput<T>(
schema: z.ZodSchema<T>,
data: unknown
): Promise<ValidationResult<T>> {
const result = schema.safeParse(data);
return result.success
? { valid: true, data: result.data }
: { valid: false, success: false, error: "Validation failed", issues: result.error.issues };
}
// getAuthContext uses dynamic import internally
export async function getAuthContext(): Promise<AuthContext | AuthError> {
const { headers } = await import("next/headers"); // Dynamic import here
const user = await getUser(await headers());
if (!user) {
return { ...(await error("Unauthorized")), authenticated: false };
}
const db = dbClient.$setAuth({ id: user.id, role: user.role });
return { authenticated: true, user, db };
}
Verification
After applying these patterns:
- Build Check: Run
pnpm buildorturbo build- should complete without errors - Type Check: Run
turbo types- should pass - Lint Check: Run
turbo lint- should pass - Runtime Test: Verify server actions work correctly in development and production
Migration Checklist
When converting existing server actions to Next.js 16:
- All exported functions are
asyncand returnPromise - Constants/enums moved to separate files without
"use server" -
next/headersuses dynamic imports (await import("next/headers")) - All helper function calls have
await - Removed
"use server"from any constant-only files - Updated callers to await all helper functions
Common Build Errors and Fixes
Error: "can only export async functions"
Cause: Exporting constants, types, or sync functions Fix: Make functions async or move exports to separate file
Error: Module forced into dynamic rendering
Cause: Static import of next/headers
Fix: Use const { headers } = await import("next/headers")
Error: Missing await
Cause: Calling async helper without await
Fix: Add await to all helper function calls
Notes
- These rules apply to all files with
"use server"directive - Next.js 15 was more lenient - Next.js 16 enforces these strictly
- Server components (without
"use server") have different rules - The
getAuthContext()helper pattern is recommended over manualrequireAuth()usage - Dynamic imports add minimal overhead (module is cached after first import)