Login Flow and Protected Routes

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.

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