Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Implementing Protected Routes with Server-Side Authentication

Hello! Welcome back.

In our last lesson, we successfully created a React context to bridge our server-side authentication state to the client-side UI. Now, both the server and the client are aware of the user's authentication status.

The next logical step is to use this server-side knowledge to enforce access control. How do we prevent an unauthenticated user from even seeing a protected page like /dashboard? How do we restrict an /admin area to users with a specific role?

This lesson will address exactly that, covering the learning outcome:

Implement protected routes using server-side authentication checks in route loaders or middleware.

We will explore the two primary, server-centric patterns for securing routes in Tanstack Start: using the beforeLoad route lifecycle hook and implementing dedicated authentication middleware. By the end, you'll be able to choose and implement the right strategy for different security requirements in your application.

1. Strategy 1: Route Protection with beforeLoad

The most direct way to protect a route in Tanstack Router is by using the beforeLoad function. This function is guaranteed to run on the server before the route component is rendered, making it the perfect place for an authentication check.

If the check fails, we can simply throw a redirect to send the user to a login page.

The official Tanstack Start documentation provides a clean and powerful pattern for this using layout routes.

Authentication | TanStack Start React Docs

The official Tanstack Start documentation demonstrates the canonical way to protect routes using beforeLoad on a layout route. This is the foundational pattern for route-level security.

Please read the section '4. Route Protection'. Pay close attention to the file routes/_authed.tsx. This is a layout route. Notice how beforeLoad calls a server function to get the user and throws a redirect if the user is not found. Also, see how the user data is returned and made available to child routes.

The Layout Route Pattern

As you saw in the documentation, the convention is to create a layout route (e.g., src/routes/_authed.tsx) that performs the authentication check. The leading underscore in _authed signifies that this is a layout route group and does not add a segment to the URL path.

Any route file placed within a corresponding src/routes/_authed/ directory will automatically become a child of this layout and inherit its protection.

Here is what the implementation looks like:

1. The Protected Layout Route (src/routes/_authed.tsx)

// src/routes/_authed.tsx
import { createFileRoute, redirect } from '@tanstack/react-router';
import { getAppSession } from '~/lib/session'; // Your session utility

export const Route = createFileRoute('/_authed')({
  // This runs on the server before any child route attempts to load.
  beforeLoad: async ({ location }) => {
    const session = await getAppSession();
    const sessionData = await session.get();

    // If not authenticated, redirect to the login page.
    if (sessionData?.status !== 'authenticated') {
      throw redirect({
        to: '/login',
        // Optional: Pass the original intended URL to redirect back after login.
        search: {
          redirect: location.href,
        },
      });
    }

    // If authenticated, return the user data to the route's context.
    return {
      user: sessionData.user,
    };
  },
});

2. A Protected Route (src/routes/_authed/dashboard.tsx)

Because this file is inside the _authed directory, the beforeLoad check from the layout will run before this component can render.

// src/routes/_authed/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router';

// This route is automatically protected by its parent layout.
export const Route = createFileRoute('/_authed/dashboard')({
  component: DashboardComponent,
});

function DashboardComponent() {
  // We can access the user data returned from the layout's beforeLoad.
  const { user } = Route.useRouteContext();

  return (
    <div>
      <h3>Welcome to your Dashboard, {user.email}!</h3>
      <p>This page is protected.</p>
    </div>
  );
}

This approach is elegant because it co-locates the protection logic with the routes themselves and provides a clear, hierarchical structure for your protected areas.

This video from Web Dev Cody provides a practical walkthrough of this exact concept.

How I Protect My Tanstack Start Applications

This video demonstrates the beforeLoad pattern in a real-world Tanstack Start application, showing how to redirect unauthenticated users away from an admin section.

Watch from 0:36 to 1:22. The presenter shows how a beforeLoad function on an /admin layout route is used to assert authentication and redirect if the user is not logged in or not an admin.

2. Extending to Authorization (Role-Based Access Control)

The same beforeLoad pattern can be easily extended from simple authentication (is the user logged in?) to more granular authorization (does the user have the required permissions?).

You simply add another check inside beforeLoad against the user's properties, such as a role.

Authentication | TanStack Start React Docs

The Tanstack Start docs also cover how to add role-based checks on top of the basic authentication check.

Now, read the 'Role-Based Access Control (RBAC)' section. It shows how you can create a separate layout route for /admin that first ensures the user is authenticated (by nesting under _authed) and then checks if they have the 'ADMIN' role.

For example, you could create an admin-only layout at src/routes/_authed/_admin.tsx:

// src/routes/_authed/_admin.tsx
import { createFileRoute, redirect } from '@tanstack/react-router';

// A helper function to check roles
function hasAdminRole(user: { roles: string[] }): boolean {
  return user.roles.includes('admin');
}

export const Route = createFileRoute('/_authed/_admin')({
  beforeLoad: async ({ context }) => {
    // The `context.user` is available because this route is a child of `/_authed`.
    const { user } = context;

    if (!hasAdminRole(user)) {
      // If the user is not an admin, redirect them away.
      throw redirect({ to: '/dashboard', search: { error: 'unauthorized' } });
    }
  },
});

Any route inside src/routes/_authed/_admin/ would now require the user to be both authenticated and have the 'admin' role.

3. Strategy 2: Route Protection with Middleware

While beforeLoad is excellent for route-specific logic, sometimes you need a more reusable, cross-cutting solution. This is where middleware comes in. Middleware allows you to define a chain of functions that run before your route loaders and actions.

This is a more advanced feature of the underlying React Router and offers greater flexibility for complex applications.

Middleware

The React Router documentation provides a comprehensive guide to middleware, explaining the core concepts and showing a clear pattern for authentication.

First, read the 'Quick Start (Framework mode)' section to understand the basic setup. Then, jump to the 'Common Patterns' section and read the 'Authentication' example. This shows how to create a reusable authMiddleware function.

Implementing Authentication Middleware

Following the pattern from the docs, the process looks like this:

1. Enable Middleware

In your vite.config.ts (or equivalent), you need to enable the feature flag for the Tanstack Router plugin:

// vite.config.ts
import { defineConfig } from 'vite';
import { tanstackRouter } from '@tanstack/router-vite-plugin';

export default defineConfig({
  plugins: [
    // ... other plugins
    tanstackRouter({
      future: {
        v7_middleware: true,
      },
    }),
  ],
});

2. Create Reusable Middleware

Create a file for your middleware, for example src/middleware/auth.ts.

// src/middleware/auth.ts
import { redirect } from '@tanstack/react-router';
import { getAppSession } from '~/lib/session';

// This middleware function can be imported and used on any route.
export const authMiddleware = async () => {
  const session = await getAppSession();
  const sessionData = await session.get();

  if (sessionData?.status !== 'authenticated') {
    throw redirect({ to: '/login' });
  }
  // If we don't throw, the chain continues to the next middleware or the route loader.
};

3. Apply Middleware to a Route

You can now apply this middleware to any route by exporting it from the route file.

// src/routes/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router';
import { authMiddleware } from '~/middleware/auth';

export const Route = createFileRoute('/dashboard')({
  // Apply the middleware
  beforeLoad: authMiddleware,
  component: DashboardComponent,
});

function DashboardComponent() {
  // ...
}

An Important Consideration: When Middleware Runs

There is a critical detail about how server middleware behaves during client-side navigation.

Middleware

Understanding the execution context of middleware is crucial to avoid security gaps. This section of the docs explains it perfectly.

Please read the subsection 'When Middleware Runs', focusing on the 'Server Middleware' part. The key takeaway is that server middleware only runs on client-side navigations if the target route has a loader or action that triggers a server request.

As the documentation explains, if you navigate from / to /dashboard on the client, and /dashboard has no loader, your authMiddleware will not run. This is because React Router optimizes by not making a network request if it doesn't need to fetch data.

To ensure your server-side authMiddleware always runs, you must add a loader to the route, even if it's empty:

// src/routes/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router';
import { authMiddleware } from '~/middleware/auth';

export const Route = createFileRoute('/dashboard')({
  beforeLoad: authMiddleware,
  // By adding a loader, we force the middleware to run on every navigation.
  loader: () => {
    return null;
  },
  component: DashboardComponent,
});

4. beforeLoad vs. Middleware: Which to Choose?

Feature beforeLoad on Layout Route Middleware
Simplicity High. Integrated directly into the route definition. Moderate. Requires a feature flag and understanding of the execution model.
Organization Excellent for hierarchical protection (e.g., all routes in a folder). Excellent for applying non-hierarchical, cross-cutting concerns.
Reusability Logic is tied to the layout route. Highly reusable. Define once, import, and apply anywhere.
Composition Can be chained via nested layouts. Designed for composition in an array: [logger, auth, featureFlagCheck].
Gotchas Relatively few. It behaves predictably. Must ensure a loader exists for it to run on client-side navigations.

Recommendation:

  • For most standard application structures, the beforeLoad on a layout route pattern is simpler, more explicit, and often the best choice.
  • If you have complex, non-hierarchical security rules or want to compose multiple cross-cutting concerns (like logging, A/B testing, and auth), middleware provides a more powerful and scalable abstraction.

Conclusion

You now have two robust, server-side patterns for implementing protected routes. By performing these checks on the server, you ensure that unauthorized users never receive the code or data for protected pages, which is fundamental for both security and performance in an SSR application.

Key Takeaways:

  • beforeLoad is the primary tool for route protection. Using it on a layout route (_authed) is a powerful and clean pattern for protecting entire sections of your app.
  • throw redirect() is the mechanism for enforcing protection. It halts the rendering process on the server and sends the user to another page.
  • Middleware is a more advanced, reusable pattern for applying cross-cutting concerns, but requires careful attention to its execution rules, particularly during client-side navigation.
  • Authorization is an extension of authentication. The same patterns can be used to check for specific user roles or permissions.

Next Up:

We have now covered the core concepts of building a fully authenticated SSR application with Tanstack Start. In the next module, we will begin the main project: migrating an existing Single-Page Application. Our first step will be to analyze an existing SPA to identify code dependent on client-side browser APIs (e.g., window, localStorage), which is a critical prerequisite for a successful SSR transformation.

Can't find a good explanation? Sign up and we'll make it for you

Sign up