DEV Community

Cover image for Protecting Routes in TanStack Start with Zustand
Wade Thomas
Wade Thomas

Posted on

Protecting Routes in TanStack Start with Zustand

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);
      },
    },
  ),
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 />;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

  1. User logs in → setUser is called → Zustand persists the user to localStorage
  2. User revisits the app → Zustand rehydrates from localStorage → hasHydrated is set to true
  3. Protected layout checks hasHydrated and user → renders MainLayout if both pass
  4. User logs out → logout calls 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)