Hello! Let's continue our journey into production-ready authentication patterns.
In our last lesson, you built a solid Role-Based Access Control (RBAC) system. You learned to protect routes with beforeLoad, tailor page content with loader functions, and secure your backend API endpoints with server function middleware. This was all based on a single check: does the user have a specific role, like 'admin'?
While effective, this simple approach can become brittle and hard to maintain as your application's rules grow more complex. What if a page is accessible to admins or moderators? What if a server function requires a user to be authenticated, a member of a specific team, and have a 'manager' role? Chaining simple if statements will quickly lead to duplicated, error-prone code.
Today's lesson addresses this head-on. Your goal is to compose multiple authentication and authorization checks in a type-safe and reusable manner. We will transform your simple RBAC checks into a powerful, scalable, and maintainable authorization system by exploring patterns for combining rules declaratively.
1. The Problem with Scaling Simple Checks
Let's start by looking at a better architectural approach than scattering if (user.role === '...)` checks throughout your code. A well-structured system defines permissions centrally.
Building a Scalable Role-Based Access Control (RBAC) ...
This article, while for Next.js, outlines a universal and highly effective architecture for scalable RBAC. It frames the problem perfectly and proposes a layered solution that we will adapt for TanStack Start.
Please read the introduction, 'Why Traditional Permission Systems Fall Short', and 'Layer 1: Building the Permission Foundation'. Focus on the idea of defining all possible permissions in an enum and then mapping roles to those permissions. This centralizes your authorization logic.
This central definition is the first step toward reusability. Instead of checking for a role string, you'd have a helper function like userHasPermission(user, Permission.DELETE_USER). This is better, but we still need a way to apply these checks efficiently and combine them.
2. Composition at the Route Level via Nesting
The simplest way to compose checks for route protection is by using the structure of your file system. TanStack Router executes beforeLoad functions sequentially from the parent route down to the child. We can leverage this to create layers of protection.
Imagine you have a set of routes that all require a user to be authenticated. Within that set, some routes are for admins only.
- Authentication Layer: Create a pathless layout route, e.g.,
_authenticated, that checks if the user is logged in. - Authorization Layer: Inside that folder, create another layout route, e.g.,
_admin, that checks if the authenticated user has the 'admin' role.
Any route inside _authenticated/_admin/ will first have its authentication checked by the parent, and then its admin role checked by the immediate layout. The checks are composed through nesting.
Complete TanStack Router Tutorial - Build Type-Safe React Apps with File-Based Routing
This video demonstrates this exact pattern. Although it uses a client-side context, the principle of using nested pathless layouts to compose beforeLoad checks is identical and provides a clear visual.
Watch from 01:13:53 to 01:17:32. First, notice the creation of a pathless _o layout to apply a general authentication check. Then, see how admin and client routes are nested inside it, each adding their own specific role check in beforeLoad. This is composition in action.
Here's how this would look in a TanStack Start project with server-side checks:
File: src/routes/_authenticated.tsx (Pathless layout for authentication)
import { createFileRoute, redirect } from '@tanstack/react-router';
import { getAuthenticatedUser } from '~/server/session'; // Your session function
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ location }) => {
const user = await getAuthenticatedUser();
if (!user) {
throw redirect({
to: '/login',
search: { redirect: location.href },
});
}
// Pass the user down to nested routes
return { user };
},
});
File: src/routes/_authenticated/_admin.tsx (Layout for admin-specific authorization)
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_authenticated/_admin')({
beforeLoad: ({ context }) => {
// The 'user' object comes from the parent `beforeLoad` context
if (context.user.role !== 'admin') {
throw redirect({ to: '/unauthorized' });
}
// No need to return anything if the check passes
},
});
Any route inside the src/routes/_authenticated/_admin/ directory is now protected by both checks.
3. Composition at the API Level: Chaining Middleware
Route protection is only half the story. You must secure your server functions. TanStack Start's middleware system is explicitly designed for composition. You can create small, single-purpose middleware functions and chain them together.
This pattern is exceptionally powerful. It keeps your logic DRY (Don't Repeat Yourself), type-safe, and easy to reason about.
TanStack Start with Drizzle & Better Auth
This video provides a masterclass in composing server function middleware in TanStack Start. It shows how to build small, reusable middleware and chain them to create robust, declarative security for server functions.
Watch carefully from 07:29 to 09:36. The key takeaway is how a userRequiredMiddleware is created by composing a more fundamental middleware that simply retrieves the session. This demonstrates how you can build layers of authorization checks, with each layer adding more context and guarantees for the next.
Let's build this pattern.
Step 1: Create Single-Purpose Middleware
// src/server/middleware.ts
import { middleware } from '@tanstack/start';
import { getAuthenticatedUser } from './session';
// Middleware 1: Checks for an authenticated user
export const isAuthenticated = middleware(async () => {
const user = await getAuthenticatedUser();
if (!user) {
throw new Error('UNAUTHORIZED'); // Will result in a 401 error
}
// Return the user in the context for the next middleware or the handler
return { user };
});
// Middleware 2: A factory that creates a role-checking middleware
export const hasRole = (requiredRole: string) => {
return middleware(async ({ context }) => {
// This assumes `isAuthenticated` ran before it and provided `context.user`
const user = (context as any).user;
if (!user || user.role !== requiredRole) {
throw new Error('FORBIDDEN'); // Will result in a 403 error
}
// No need to return anything, just pass or throw
});
};
Step 2: Chain the Middleware in a Server Function
Now, you can compose these pieces declaratively when creating a server function.
// src/server/user-actions.ts
import { createServerFn } from '@tanstack/react-start';
import { isAuthenticated, hasRole } from './middleware';
export const deleteUserFn = createServerFn(
'POST',
// Chain of middleware runs in order
isAuthenticated,
hasRole('admin'),
// The final handler function
async (userId: string, { user }) => {
// Thanks to the middleware, TypeScript knows `user` exists and is fully typed.
// The logic is clean, focusing only on the business task.
console.log(`Admin ${user.email} is deleting user ${userId}`);
// ... database logic to delete the user
}
);
This is a huge improvement. The authorization logic is declarative and separated from the business logic.
Test your understanding!
You need a server function to create a "team-only" resource. This requires the user to be authenticated and a member of a specific team. Assume you have a isTeamMember middleware that takes a teamId and checks a database table.
How would you compose the createServerFn?
const isTeamMember = (teamId: string) => middleware(...)
Show answer
You would chain the isAuthenticated middleware with the isTeamMember middleware. The handler would receive the teamId from its payload.
export const createTeamResourceFn = createServerFn(
'POST',
isAuthenticated,
// The handler will need the teamId to pass to the middleware
// We can do this in a wrapper middleware
middleware(async ({ payload }) => {
const teamId = payload.teamId; // Assuming teamId is in the request body
// Here we would need a more advanced pattern, let's simplify for now.
// A better approach is shown in the next section!
// For now, let's assume the check happens inside the handler after auth.
// The key idea is the intent to compose checks.
}),
async (payload: { teamId: string; data: any }, { user }) => {
// A simplified check inside the handler for this example:
const isMember = await checkTeamMembership(user.id, payload.teamId);
if (!isMember) throw new Error('FORBIDDEN');
// ... create the resource
}
);
This reveals a limitation of our current hasRole factory. The next section provides a more elegant solution for this exact problem.
4. Advanced Reusability: Parametrized Function Builders
Chaining middleware is great, but we can abstract it even further for maximum reusability and type safety. Instead of exporting createServerFn directly, we can create our own "flavored" builders that encapsulate common authorization patterns.
This concept, borrowed from frameworks like Convex, is incredibly powerful. We can create a builder that takes the authorization requirements as arguments.
Authorization Best Practices and Implementation Guide
This guide from Convex, though for a different framework, presents a brilliant pattern using 'custom functions' that we can adapt. It shows how to parametrize these builders to make authorization checks a type-safe part of the function's definition.
Read the section 'Parametrizing custom functions to consolidate shared logic'. Focus on how the teamMutation builder is defined to accept a role option, which is then used inside its input function to perform the check. This is the pattern we will now replicate.
Let's build a createProtectedServerFn that takes a required role.
// src/server/builders.ts
import { createServerFn, middleware } from '@tanstack/start';
import { getAuthenticatedUser } from './session';
type Handler<T, U, C> = (payload: T, context: C) => U;
// Our custom builder
export function createProtectedServerFn<TPayload, TResult>(
opts: { requiredRole: 'admin' | 'moderator' | 'user' },
handler: Handler<TPayload, TResult, { user: NonNullable<Awaited<ReturnType<typeof getAuthenticatedUser>>> }>
) {
// Define the middleware chain internally
const authMiddleware = middleware(async () => {
const user = await getAuthenticatedUser();
if (!user) throw new Error('UNAUTHORIZED');
if (user.role !== opts.requiredRole) throw new Error('FORBIDDEN');
return { user };
});
return createServerFn('POST', authMiddleware, handler);
}
Usage:
Now, defining a protected server function becomes incredibly clean and type-safe.
// src/server/admin-actions.ts
import { createProtectedServerFn } from './builders';
export const demoteUserFn = createProtectedServerFn(
{ requiredRole: 'admin' },
// The handler's `context` is automatically typed with a non-null admin `user`!
async (payload: { userId: string }, { user }) => {
console.log(`Admin ${user.email} is demoting user ${payload.userId}`);
// ... logic
}
);
This pattern is the epitome of type-safe, reusable composition. The authorization logic is completely abstracted away, and TypeScript ensures you provide the required role and gives you a correctly typed user object in your handler.
Conclusion
Today you've leveled up your authorization system from simple checks to a scalable, composed, and type-safe architecture. You've learned to apply security at multiple layers using patterns that promote reusability and clarity.
Key Takeaways:
- Composition by Nesting: You can compose route protection by nesting layouts and applying sequential
beforeLoadchecks. - Composition by Chaining: You can secure server functions by chaining small, single-purpose middleware, separating security concerns from business logic.
- Composition by Abstraction: The most powerful pattern is to create custom, parametrized server function builders (
createProtectedServerFn) that encapsulate middleware chains, providing a declarative and type-safe API for your team.
You now have the tools to build an authorization system that can handle complex business rules without sacrificing maintainability or security.
In our next lesson, we will shift our focus to observability and robustness by learning how to manage and log server-side failures during the OAuth token exchange and user info retrieval process.
Can't find a good explanation? Sign up and we'll make it for you
Sign up