Overview
The Next.js SaaS Starter uses two types of middleware:
- Global Middleware: Runs on every request to handle session management and route protection
- Action Middleware: Wraps Server Actions to provide validation, authentication, and team context
Global Middleware
Implementation
The global middleware is defined in middleware.ts at the root of the project:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { signToken, verifyToken } from '@/lib/auth/session';
const protectedRoutes = '/dashboard';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const sessionCookie = request.cookies.get('session');
const isProtectedRoute = pathname.startsWith(protectedRoutes);
if (isProtectedRoute && !sessionCookie) {
return NextResponse.redirect(new URL('/sign-in', request.url));
}
let res = NextResponse.next();
if (sessionCookie && request.method === 'GET') {
try {
const parsed = await verifyToken(sessionCookie.value);
const expiresInOneDay = new Date(Date.now() + 24 * 60 * 60 * 1000);
res.cookies.set({
name: 'session',
value: await signToken({
...parsed,
expires: expiresInOneDay.toISOString()
}),
httpOnly: true,
secure: true,
sameSite: 'lax',
expires: expiresInOneDay
});
} catch (error) {
console.error('Error updating session:', error);
res.cookies.delete('session');
if (isProtectedRoute) {
return NextResponse.redirect(new URL('/sign-in', request.url));
}
}
}
return res;
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
runtime: 'nodejs'
};
Middleware Features
Route protection
Checks if the request is for a protected route and redirects to sign-in if no session exists.
Session verification
Verifies the JWT token on every GET request to ensure it’s still valid.
Session refresh
Automatically refreshes the session token to extend the expiration time.
Error handling
Deletes invalid sessions and redirects to sign-in if on a protected route.
Sessions are only refreshed on GET requests to avoid issues with form submissions and API calls.
Protected Routes Configuration
Currently, the middleware protects all routes starting with /dashboard:
const protectedRoutes = '/dashboard';
To protect multiple route patterns, use an array:
const protectedRoutes = ['/dashboard', '/settings', '/admin'];
const isProtectedRoute = protectedRoutes.some(route => pathname.startsWith(route));
Matcher Configuration
The matcher excludes certain paths from middleware processing:
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
runtime: 'nodejs'
};
This pattern excludes:
- API routes (
/api/*)
- Next.js static files (
/_next/static/*)
- Next.js image optimization (
/_next/image/*)
- Favicon (
/favicon.ico)
The matcher uses a negative lookahead regex. Be careful when modifying it to ensure you don’t accidentally block necessary routes.
Action Middleware
Action middleware provides security and validation for Server Actions. These are defined in lib/auth/middleware.ts.
Type Definitions
export type ActionState = {
error?: string;
success?: string;
[key: string]: any;
};
type ValidatedActionFunction<S extends z.ZodType<any, any>, T> = (
data: z.infer<S>,
formData: FormData
) => Promise<T>;
type ValidatedActionWithUserFunction<S extends z.ZodType<any, any>, T> = (
data: z.infer<S>,
formData: FormData,
user: User
) => Promise<T>;
type ActionWithTeamFunction<T> = (
formData: FormData,
team: TeamDataWithMembers
) => Promise<T>;
validatedAction
Validates form data against a Zod schema:
export function validatedAction<S extends z.ZodType<any, any>, T>(
schema: S,
action: ValidatedActionFunction<S, T>
) {
return async (prevState: ActionState, formData: FormData) => {
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { error: result.error.errors[0].message };
}
return action(result.data, formData);
};
}
Usage Example
const signInSchema = z.object({
email: z.string().email().min(3).max(255),
password: z.string().min(8).max(100)
});
export const signIn = validatedAction(signInSchema, async (data, formData) => {
const { email, password } = data;
// ... authentication logic
});
Use validatedAction for public actions like sign-in and sign-up that don’t require authentication.
validatedActionWithUser
Validates form data and ensures the user is authenticated:
export function validatedActionWithUser<S extends z.ZodType<any, any>, T>(
schema: S,
action: ValidatedActionWithUserFunction<S, T>
) {
return async (prevState: ActionState, formData: FormData) => {
const user = await getUser();
if (!user) {
throw new Error('User is not authenticated');
}
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { error: result.error.errors[0].message };
}
return action(result.data, formData, user);
};
}
Usage Example
const updatePasswordSchema = z.object({
currentPassword: z.string().min(8).max(100),
newPassword: z.string().min(8).max(100),
confirmPassword: z.string().min(8).max(100)
});
export const updatePassword = validatedActionWithUser(
updatePasswordSchema,
async (data, _, user) => {
const { currentPassword, newPassword, confirmPassword } = data;
const isPasswordValid = await comparePasswords(
currentPassword,
user.passwordHash
);
if (!isPasswordValid) {
return { error: 'Current password is incorrect.' };
}
// ... password update logic
}
);
The user parameter is automatically provided by the middleware, so you can access user data without additional queries.
withTeam
Ensures the user is authenticated and has team context:
export function withTeam<T>(action: ActionWithTeamFunction<T>) {
return async (formData: FormData): Promise<T> => {
const user = await getUser();
if (!user) {
redirect('/sign-in');
}
const team = await getTeamForUser();
if (!team) {
throw new Error('Team not found');
}
return action(formData, team);
};
}
Usage Example
export const someTeamAction = withTeam(async (formData, team) => {
// team is guaranteed to exist here
const memberCount = team.teamMembers.length;
// ... team-specific logic
});
Use withTeam when your action needs access to the full team data including members.
Middleware Comparison
// No authentication required
// Validates schema only
export const publicAction = validatedAction(
schema,
async (data, formData) => {
// data is validated
// no user context
}
);
Best Practices
Always use middleware wrappers for Server Actions that modify data. Never trust client-side validation alone.
Chain validation by using validatedActionWithUser for actions that need both schema validation and user context.
Middleware runs on the server only. Client components cannot access middleware-protected data directly.
Session Refresh Flow
The global middleware implements automatic session refresh:
Request arrives
User makes a GET request to any route.
Token verification
Middleware verifies the JWT token from the session cookie.
Token refresh
If valid, a new token is created with a fresh 24-hour expiration.
Cookie update
The response includes the updated session cookie.
This ensures users remain logged in as long as they’re actively using the application.
Error Handling
Global Middleware Errors
try {
const parsed = await verifyToken(sessionCookie.value);
// ... refresh session
} catch (error) {
console.error('Error updating session:', error);
res.cookies.delete('session');
if (isProtectedRoute) {
return NextResponse.redirect(new URL('/sign-in', request.url));
}
}
Action Middleware Errors
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { error: result.error.errors[0].message };
}
Validation errors are returned as part of the action state, allowing you to display them in the UI.