Route protection in TanStack Start doesn't have a built-in auth guard out of the box, but combining a Zustand store with a layout route gives you a clean, reusable solution that works well with SSR and hydration.
Here's the full setup.
The Auth Store
First, the Zustand store. It persists the user to localStorage via the persist middleware and uses a hasHydrated flag to track when the store has been rehydrated from storage. This is critical — without it you'll get a flash of redirect on page load even for logged-in users.
import type { User } from '@/types';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { logoutUser } from '@/lib/directus';
interface AuthState {
user: User | null;
hasHydrated: boolean;
setHasHydrated: (state: boolean) => void;
setUser: (user: User) => void;
logout: () => Promise<void>;
isLoggedIn: () => boolean;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
hasHydrated: false,
setHasHydrated: (state) => set({ hasHydrated: state }),
setUser: (user) => set({ user }),
logout: async () => {
await logoutUser();
set({ user: null });
},
isLoggedIn: () => !!get().user,
}),
{
name: 'auth-storage',
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
},
},
),
);
The onRehydrateStorage callback fires once Zustand has finished reading from localStorage and sets hasHydrated to true. This is the signal your protected layout waits for before making any redirect decisions.
The User Type
The User type maps directly to the Directus user fields you need in your app.
export interface User {
id: string;
first_name: string;
last_name: string;
email: string;
avatar: string | null;
location?: string;
description?: string;
}
The Protected Layout Route
TanStack Start's file-based routing lets you create layout routes that wrap child routes. The _protectedLayout route acts as an auth guard — it checks the store and redirects to login if no user is present.
import MainLayout from '@/components/layouts/MainLayout';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useAuthStore } from '@/store/authStore';
import { useEffect } from 'react';
export const Route = createFileRoute('/auth/_protectedLayout')({
component: ProtectedLayout,
notFoundComponent: () => {
return <p>This page doesn't exist!</p>;
},
});
function ProtectedLayout() {
const { user, hasHydrated } = useAuthStore();
const navigate = useNavigate();
useEffect(() => {
if (hasHydrated && !user) {
navigate({ to: '/auth/login' });
}
}, [user, hasHydrated]);
if (!hasHydrated) return null;
if (!user) return null;
return <MainLayout />;
}
The two guard conditions are important:
-
if (!hasHydrated) return null— waits for the store to rehydrate before rendering anything, preventing a flash of redirect -
if (!user) return null— prevents rendering the layout while the redirect is in flight
Only when both conditions pass does MainLayout render.
The Main Layout
MainLayout is a standard shell component that renders your header, footer and the current route's content via Outlet.
import { Outlet } from '@tanstack/react-router';
import Header from '../Header';
import { Footer } from '../Footer';
export default function MainLayout() {
return (
<div>
<Header />
<div className="max-w-7xl mx-auto p-4">
<Outlet />
</div>
<Footer />
</div>
);
}
Any route nested under _protectedLayout automatically gets the auth guard and the shared layout — no need to add protection logic to individual routes.
How It All Fits Together
- User logs in →
setUseris called → Zustand persists the user to localStorage - User revisits the app → Zustand rehydrates from localStorage →
hasHydratedis set totrue - Protected layout checks
hasHydratedanduser→ rendersMainLayoutif both pass - User logs out →
logoutcalls Directus, clears the user from the store → redirect to login
This pattern keeps auth logic in one place, works cleanly with SSR hydration, and scales to as many protected routes as you need without repeating any guard logic.
Have questions about the setup? Drop a comment below.
Top comments (0)