Introduction: Why Login and Protected Routes Matter

Welcome back! In the previous lesson, you learned how to set up an API client so your React app can talk to your NestJS backend. Now, let’s take the next step: making sure only logged-in users can access certain parts of your app.

Most modern web apps need to know who their users are. This is called authentication. For example, you might want anyone to see your catalog, but only logged-in users should see their personal shelf. To do this, you need a way for users to log in and a way to protect certain routes so only authenticated users can access them.

In this lesson, you will learn how to:

  • Build a login form that talks to your backend.
  • Store a user’s login token.
  • Protect routes so only logged-in users can visit them.

Let’s get started!

Quick Recap: Routing Setup

Before we dive into authentication, let’s quickly remind ourselves how routing is set up in your app. You already have a router that defines which component shows up for each URL. Here’s a simplified version of your router setup:

import { createBrowserRouter } from "react-router-dom";
import App from "../App";
import HomePage from "../pages/HomePage";
import CatalogPage from "../features/catalog/CatalogPage";
import MyShelfPage from "../features/shelf/MyShelfPage";
import LoginPage from "../pages/LoginPage";

export const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
    children: [
      { index: true, element: <HomePage /> },
      { path: "catalog", element: <CatalogPage /> },
      { path: "login", element: <LoginPage /> },
      { path: "shelf", element: <MyShelfPage /> },
    ],
  },
]);

This setup lets users visit /, /catalog, /login, and /shelf. Right now, anyone can visit any page. In this lesson, you’ll learn how to make /shelf available only to logged-in users.

Building the Login Flow

Let’s start by building the login flow. This means creating a form where users can enter their username and password, sending that data to your backend, and saving the token you get back. Below we’ll turn a simple form into a working login flow that talks to /auth/login, stores the returned token, and redirects the user. We’ll also add a post method to apiClient, explain useNavigate, and detail how the three useState hooks, e.preventDefault(), and error handling work together.

Here’s the code for your login form:

import { useState, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { apiClient } from "../../api/client";
import { saveToken } from "../../utils/auth";

export default function LoginForm() {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const navigate = useNavigate();

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    setError("");
    try {
      const response = await apiClient.post("/auth/login", { username, password });
      saveToken(response.data?.data?.access_token);
      navigate("/shelf");
    } catch (err) {
      setError("Invalid username or password.");
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4 max-w-sm mx-auto">
      {error && <p className="text-red-400 bg-red-900/50 p-3 rounded-md">{error}</p>}
      <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" required className="w-full bg-slate-800 p-2 rounded-md" />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" required className="w-full bg-slate-800 p-2 rounded-md" />
      <button type="submit" className="w-full bg-sky-500 hover:bg-sky-600 p-2 rounded-md font-semibold">Login</button>
    </form>
  );
}

How the login form works (line by line)

  • Three useState hooks

    • username / setUsername: Holds the current value of the username input; updates on each keystroke so the form is fully controlled by React.
    • password / setPassword: Same for the password field.
    • error / setError: Keeps an error message to show above the form. Cleared before each attempt; set when the API rejects credentials or a network error occurs.
  • e.preventDefault()

    • Stops the browser from doing a full page reload on form submit.
    • Lets React handle submission logic asynchronously (call API, update state, navigate) without leaving the SPA context.
  • apiClient.post

    • Sends a POST to /auth/login with { username, password }.
    • Returns the parsed envelope. We check success === true and then read data.access_token.
  • useNavigate()

    • Returns a function you can call to imperatively navigate after side effects (e.g., login).
    • navigate('/shelf', { replace: true }) pushes a transition to /shelf and replaces the current history entry so the back button doesn’t take the user back to the login page.
  • Token handling

    • On success, we persist the token via saveToken(token) to localStorage so the user stays logged in across refreshes.
    • On failure, we set a user-friendly error string that renders in the alert area.

This makes the login flow predictable, testable, and aligned with the backend’s response contract.

Why TOKEN_KEY, and where do we store it?
  • TOKEN_KEY: A constant string used as the storage key. Keeping it centralized avoids typos and makes refactors (like namespacing) trivial.
  • localStorage: A browser key–value store that persists across tabs and page reloads for the same origin. Ideal for remembering login sessions after refresh.
  • Security note: Any token stored in web storage is vulnerable to XSS. Keep your app free of injection risks. If you later use HTTP-only cookies, you trade off different concerns (CSRF).
  • sessionStorage (FYI): Same API, but cleared when the tab closes. Prefer it if you want logins to expire with the tab/session rather than persist.
const TOKEN_KEY = "shelf-pilot-token";

export function saveToken(token: string): void {
  localStorage.setItem(TOKEN_KEY, token);
}

export function getToken(): string | null {
  return localStorage.getItem(TOKEN_KEY);
}

export function removeToken(): void {
  localStorage.removeItem(TOKEN_KEY);
}

This code uses the browser’s localStorage to keep the token even if the user refreshes the page.

What does the user see?

  • If the login is successful, they are taken to /shelf.
  • If the login fails, they see:
    Invalid username or password.
Adding post to apiClient
// Extend the transport facade with POST support:
export const apiClient = {
  get:  <T = any>(path: string) => request<T>(path),
  post: <T = any>(path: string, body?: any) =>
    request<T>(path, {
      method: 'POST',
      body: body ? JSON.stringify(body) : undefined,
      // headers are injected in request<T>() based on presence of body
    }),
};

Why add post here?

  • Single source of truth for transport: apiUtils centralizes how requests are made (headers, base URL selection, error parsing). Adding post here means every feature can use apiClient.post(...) without re-implementing fetch details.
  • Automatic JSON handling: The request<T>() helper adds Content-Type: application/json when a body exists and parses JSON (or returns text) uniformly, so your feature code stays clean.
  • Consistent behavior: Timeouts, auth headers, or retry strategies (if you add them later) can be applied in one place.

With post available, you can now call apiClient.post('/auth/login', { username, password }) in the login form.

Protecting Routes with ProtectedRoute

Now, let’s make sure only logged-in users can visit the /shelf page. We do this by creating a special component called ProtectedRoute. Once we have a token, we must gate certain routes so only authenticated users can access them. ProtectedRoute reads the token and either renders children or redirects to /login.

Here’s the code:

import { Navigate, Outlet } from "react-router-dom";
import { getToken } from "../utils/auth";

export default function ProtectedRoute() {
  const token = getToken();

  if (!token) {
    return <Navigate to="/login" replace />;
  }

  return <Outlet />;
}

How does this work?

  • Token check: Reads from localStorage via getToken(). If no token, the user is considered unauthenticated.
  • Redirect: <Navigate to="/login" replace /> sends the user to the login page and replaces history so they don’t “Back” into a protected URL.
  • Outlet: When authenticated, <Outlet /> renders the matched child route inside the current layout, preserving headers/footers.

Now we make /shelf a child of ProtectedRoute.

We should also update our router to use ProtectedRoute for the /shelf route:

import { createBrowserRouter } from 'react-router-dom';
import App from '../App';
import HomePage from '../pages/HomePage';
import CatalogPage from '../features/catalog/CatalogPage';
import MyShelfPage from '../features/shelf/MyShelfPage';
import LoginPage from '../pages/LoginPage';
import ProtectedRoute from './ProtectedRoute';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <App />,
    children: [
      { index: true, element: <HomePage /> },
      { path: 'catalog', element: <CatalogPage /> },
      { path: 'login', element: <LoginPage /> },
      // rest of the routes
      {
        element: <ProtectedRoute />,
        children: [
          { path: 'shelf', element: <MyShelfPage /> }, // gated by token
        ],
      },
    ],
  },
]);

What does this do?

  • Visiting /shelf without a token → redirected to /login.
  • After successful login, we store the token and navigate('/shelf').
  • Refreshing on /shelf still works because the token persists in localStorage.

NOTE: AuthContext and useAuth (Coming Later)

We’ll introduce an AuthContext that exposes a useAuth() hook with helpers such as:

  • saveToken(token): persist and broadcast to the app state.
  • setToken(token): update the in-memory token so components re-render immediately.
  • removeToken(): clear storage and state on logout, then redirect.

For now, we focused on the mechanics of login and route protection and laid the groundwork for the registration flow. When we add AuthContext in the final unit, components will react to auth changes instantly (no manual reload), and we’ll centralize token injection for API calls.


Summary And What’s Next

In this lesson, you learned how to:

  • Build a login form that talks to your backend and saves a token.
  • Use that token to protect certain routes in your app.
  • Redirect users to the login page if they are not authenticated.

These are the building blocks for user authentication in your app. Next, you’ll get to practice these concepts with hands-on exercises. You’ll try logging in, storing tokens, and protecting routes yourself. This will help you get comfortable with real-world authentication flows in React.

Great job making it this far — let’s keep going!

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal