Goal

Install TanStack Router, create real URLs (/login, /jobs, /saved), and guard private pages with a pathless _authenticated layout that redirects when there is no token cookie / Redux token.

You already learned TanStack Router basics in the React module. Here you wire it to RTK auth.

1. Install packages

npm install @tanstack/react-router@1.65.0
npm install -D @tanstack/router-plugin@1.65.0 @tanstack/router-devtools@1.65.0

2. Update vite.config.js

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";

export default defineConfig({
  plugins: [TanStackRouterVite(), tailwindcss(), react()],
});

Put the router plugin before React so file-based routes generate correctly.

Restart npm run dev after this change. TanStack will create src/routeTree.gen.ts when route files exist. Add that file to .gitignore if you like (it regenerates).

3. Root layout , src/routes/__root.jsx

import { useEffect } from "react";
import { useSelector } from "react-redux";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import Navbar from "../components/Navbar";

export const Route = createRootRoute({
  component: RootLayout,
});

function RootLayout() {
  const theme = useSelector((state) => state.ui.theme);

  useEffect(() => {
    document.documentElement.classList.toggle("dark", theme === "dark");
  }, [theme]);

  return (
    <div className="min-h-screen bg-surface font-sans text-ink antialiased dark:bg-slate-950 dark:text-slate-100">
      <Navbar />
      <main className="mx-auto max-w-5xl px-6 py-10">
        <Outlet />
      </main>
      <TanStackRouterDevtools position="bottom-right" />
    </div>
  );
}

Chunks explained

Chunk What it does
createRootRoute Shell for every URL
<Outlet /> Where child routes render
Theme useEffect Keeps dark mode in sync on all pages

4. Login route , src/routes/login.jsx

import { createFileRoute, redirect } from "@tanstack/react-router";
import { store } from "../store";
import { getCookie } from "../utils/cookies";
import LoginForm from "../components/LoginForm";

export const Route = createFileRoute("/login")({
  beforeLoad: () => {
    const token = store.getState().auth.token || getCookie("hireboard-token");
    if (token) {
      throw redirect({ to: "/jobs" });
    }
  },
  component: LoginPage,
});

function LoginPage() {
  return <LoginForm />;
}

Chunks explained

Chunk What it does
beforeLoad Runs before the route renders
store.getState() Read auth outside React
redirect({ to: "/jobs" }) Already logged in → skip login page

5. Protected layout , src/routes/_authenticated.jsx

The _ prefix makes a pathless layout: it does not add a URL segment, but wraps child routes.

import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { store } from "../store";
import { getCookie } from "../utils/cookies";

export const Route = createFileRoute("/_authenticated")({
  beforeLoad: ({ location }) => {
    const token = store.getState().auth.token || getCookie("hireboard-token");
    if (!token) {
      throw redirect({
        to: "/login",
        search: {
          redirect: location.href,
        },
      });
    }
  },
  component: AuthenticatedLayout,
});

function AuthenticatedLayout() {
  return <Outlet />;
}

Chunks explained

Chunk What it does
/_authenticated Pathless parent for private pages
No token → /login Server-style guard (not just hiding UI)
search.redirect Optional return URL after login
Child <Outlet /> Renders /jobs or /saved

This is your protected route. Every private page sits under this layout.

6. Jobs and Saved routes

src/routes/_authenticated/jobs.jsx:

import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/_authenticated/jobs")({
  component: JobsPage,
});

function JobsPage() {
  return (
    <section>
      <h2 className="mb-2 text-2xl font-semibold">Open roles</h2>
      <p className="text-muted dark:text-slate-400">
        Protected route works. Real job API comes next.
      </p>
    </section>
  );
}

src/routes/_authenticated/saved.jsx:

import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/_authenticated/saved")({
  component: SavedPage,
});

function SavedPage() {
  return (
    <section>
      <h2 className="mb-2 text-2xl font-semibold">Saved applications</h2>
      <p className="text-muted dark:text-slate-400">
        Only visible when authenticated.
      </p>
    </section>
  );
}

Public URLs become /jobs and /saved (the _authenticated segment is not in the path).

7. Index redirect , src/routes/index.jsx

import { createFileRoute, redirect } from "@tanstack/react-router";
import { store } from "../store";
import { getCookie } from "../utils/cookies";

export const Route = createFileRoute("/")({
  beforeLoad: () => {
    const token = store.getState().auth.token || getCookie("hireboard-token");
    throw redirect({ to: token ? "/jobs" : "/login" });
  },
});

8. Slim App.jsx + main.jsx

App.jsx:

import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";

const router = createRouter({ routeTree });

function App() {
  return <RouterProvider router={router} />;
}

export default App;

main.jsx stays Provider → App:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { store } from "./store";
import App from "./App.jsx";
import "./style.css";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </StrictMode>,
);
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
import { useDispatch, useSelector } from "react-redux";
import { logout } from "../store/authSlice";
import ThemeToggle from "./ThemeToggle";

function Navbar() {
  const user = useSelector((state) => state.auth.user);
  const dispatch = useDispatch();
  const navigate = useNavigate();
  const pathname = useRouterState({ select: (s) => s.location.pathname });

  const linkBase = "rounded-md px-3 py-2 text-sm font-medium transition";
  const active = "bg-brand-600 text-white";
  const idle =
    "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800";

  function handleLogout() {
    dispatch(logout());
    navigate({ to: "/login" });
  }

  return (
    <header className="sticky top-0 z-10 border-b border-slate-200 bg-card/90 backdrop-blur dark:border-slate-800 dark:bg-slate-900/90">
      <div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 py-3">
        <div className="flex items-center gap-6">
          <Link
            to={user ? "/jobs" : "/login"}
            className="text-lg font-semibold text-brand-600"
          >
            HireBoard
          </Link>
          {user && (
            <nav className="flex gap-1">
              <Link
                to="/jobs"
                className={`${linkBase} ${pathname === "/jobs" ? active : idle}`}
              >
                Jobs
              </Link>
              <Link
                to="/saved"
                className={`${linkBase} ${pathname === "/saved" ? active : idle}`}
              >
                Saved
              </Link>
            </nav>
          )}
        </div>

        <div className="flex items-center gap-3">
          {user && (
            <>
              <span className="hidden text-sm text-muted sm:inline dark:text-slate-400">
                {user.firstName} {user.lastName}
              </span>
              <button
                type="button"
                onClick={handleLogout}
                className="rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800"
              >
                Log out
              </button>
            </>
          )}
          <ThemeToggle />
        </div>
      </div>
    </header>
  );
}

export default Navbar;

Chunks explained

Chunk What it does
<Link to="/jobs"> Client-side navigation (no full reload)
useRouterState Read current path for active styles
navigate({ to: "/login" }) After logout, leave protected URLs

10. Navigate after login in LoginForm

After setCredentials, send the user to Jobs:

import { useNavigate } from "@tanstack/react-router";

// inside component:
const navigate = useNavigate();

// after dispatch(setCredentials(data)):
navigate({ to: "/jobs" });

11. Session restore checklist

Key Storage Purpose
hireboard-token Cookie Access token for guards + API headers
hireboard-user localStorage Profile for the navbar
hireboard-theme localStorage Light / dark

On refresh of /jobs:

  1. Store hydrates token from cookie and user from localStorage.
  2. _authenticated beforeLoad sees the token → allows the route.
  3. Navbar shows the user’s name.

Security note

Practice Reality in this demo
Token cookie set from JS Fine for learning; production prefers httpOnly cookies from the server
beforeLoad redirect Protects UI routes , APIs must still validate tokens
DummyJSON JWT Demo credentials only

Check

  • Visit /jobs while logged out → redirect to /login.
  • Sign in → land on /jobs; URL bar shows /jobs.
  • Visit /saved → works only when authenticated.
  • Log out → cookie + user cleared; back on /login.
  • Refresh on /jobs while logged in → still there.

Assessment: Taskly : Protected routes

Install TanStack Router. Create /login, /tasks, and /pinned. Guard /tasks and /pinned with a pathless _authenticated layout (beforeLoad + redirect when no token).

Done when: guests cannot open /tasks or /pinned; login lands on /tasks; logout returns to /login.

Next: fetch real jobs from JSONFakery with RTK Query.