Sign In and Register Pages with Protected Routes in React

MERN Stack Full Course: MongoDB, Express, React and Node.js · Connecting Everything · 13 min read · Updated

A protected route

  1. A visitor who is not signed in opens a page that needs an account.
  2. The ProtectedRoute wrapper looks at the auth context.
  3. No user: it redirects to the login page and remembers the address they wanted.
  4. After a successful login the token is saved, and the visitor goes back to the page they wanted.
  5. If the server later replies 401, the app signs the person out and shows the login page again.

It is time to remove the temporary demo login from lessons 28 to 31 and let real people create accounts. This lesson connects the pieces from the back end (register, login, /auth/me, 401 answers) to React: pages for signing in and registering, a way to restore a session after a refresh, protected routes, and a clean sign out.

The auth context, for real

import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { api, getToken, setToken } from '../api.js';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [ready, setReady] = useState(false); // false while we check a saved token

  // On first load: if there is a saved token, ask the server who it belongs to.
  useEffect(() => {
    const controller = new AbortController();
    (async () => {
      if (!getToken()) { setReady(true); return; }
      try {
        const res = await api('/auth/me', { signal: controller.signal });
        setUser(res.user);
      } catch (err) {
        if (err.name === 'AbortError') return;
        setToken(null); // the token is no good any more
      }
      setReady(true);
    })();
    return () => controller.abort();
  }, []);

  const signOut = useCallback(() => { setToken(null); setUser(null); }, []);

  // If any request later gets a 401, sign out so the person lands on the login page.
  useEffect(() => {
    window.addEventListener('jt:unauthorized', signOut);
    return () => window.removeEventListener('jt:unauthorized', signOut);
  }, [signOut]);

  const signIn = useCallback(async (email, password) => {
    const res = await api('/auth/login', { method: 'POST', body: { email, password } });
    setToken(res.token);
    setUser(res.user);
  }, []);

  const register = useCallback(async (name, email, password) => {
    const res = await api('/auth/register', { method: 'POST', body: { name, email, password } });
    setToken(res.token);
    setUser(res.user);
  }, []);

  return <AuthContext.Provider value={{ user, ready, signIn, register, signOut }}>{children}</AuthContext.Provider>;
}

export const useAuth = () => {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
  return ctx;
};
  • On first load, if a token is saved, it asks the server GET /api/auth/me. A valid answer restores the user; an error removes the bad token. Only then is ready set to true. A saved token is a *claim*, so the server must confirm it.
  • `signIn` and `register` call the API, save the token with setToken, and set user.
  • `signOut` deletes the token and clears the user. Because the token is stateless, “signing out” only forgets it on this device. The server has no session to end.
  • A listener for `jt:unauthorized`. If any request later gets a 401 (an expired token, for instance), the API helper fires this event and the context signs the person out, so they land on the login page with no extra code in any page.
const TOKEN_KEY = 'jt_token';

export const getToken = () => localStorage.getItem(TOKEN_KEY);
export const setToken = (t) => (t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY));

export class ApiError extends Error {
  constructor(status, message, details) {
    super(message);
    this.status = status;
    this.details = details;
  }
}

export async function api(path, { method = 'GET', body, signal } = {}) {
  const headers = {};
  if (body !== undefined) headers['Content-Type'] = 'application/json';
  const token = getToken();
  if (token) headers.Authorization = `Bearer ${token}`;

  const res = await fetch(`/api${path}`, { method, headers, signal, body: body !== undefined ? JSON.stringify(body) : undefined });
  if (res.status === 204) return null;
  const data = await res.json().catch(() => null);
  if (!res.ok) {
    // An expired or invalid token: tell the app so it can sign the person out. (Not for login itself.)
    if (res.status === 401 && token && !path.startsWith('/auth/login')) window.dispatchEvent(new Event('jt:unauthorized'));
    throw new ApiError(res.status, data?.message || `Request failed (${res.status})`, data?.details);
  }
  return data;
}

Look at the 401 branch in api(). It dispatches the event only when a token existed and the request was not the login itself, because a wrong password on the login form is not an expired session.

Protecting pages

import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';

// Wrap private pages in this. Signed-out visitors are sent to /login and returned here afterwards.
// (This only hides the screens. The real protection is the server, which checks the token on every call.)
export default function ProtectedRoute() {
  const { user, ready } = useAuth();
  const location = useLocation();
  if (!ready) return <p className="muted">Loading…</p>;
  if (!user) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
  return <Outlet />;
}

ProtectedRoute is a layout route. It waits while ready is false, redirects to /login when there is no user, and otherwise draws <Outlet />, the protected page. <Navigate replace state={{ from }} /> remembers where the person wanted to go, so after signing in we can send them there. replace avoids leaving the redirect in the back-button history.

import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout.jsx';
import ProtectedRoute from './components/ProtectedRoute.jsx';
import JobsPage from './pages/JobsPage.jsx';
import JobDetailPage from './pages/JobDetailPage.jsx';
import LoginPage from './pages/LoginPage.jsx';
import RegisterPage from './pages/RegisterPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';

export default function App() {
  return (
    <Routes>
      <Route element={<Layout />}>
        <Route path="login" element={<LoginPage />} />
        <Route path="register" element={<RegisterPage />} />
        <Route element={<ProtectedRoute />}>
          <Route index element={<JobsPage />} />
          <Route path="jobs/:id" element={<JobDetailPage />} />
        </Route>
        <Route path="*" element={<NotFoundPage />} />
      </Route>
    </Routes>
  );
}

The two forms

import { useState } from 'react';
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';

export default function LoginPage() {
  const { user, signIn } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [busy, setBusy] = useState(false);

  if (user) return <Navigate to="/" replace />;

  async function submit(e) {
    e.preventDefault();
    setBusy(true);
    setError('');
    try {
      await signIn(email, password);
      navigate(location.state?.from || '/', { replace: true });
    } catch (err) {
      setError(err.message);
      setBusy(false);
    }
  }

  return (
    <form className="card form narrow" onSubmit={submit}>
      <h1 className="wide">Sign in</h1>
      <div className="field wide">
        <label htmlFor="email">Email</label>
        <input id="email" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <div className="field wide">
        <label htmlFor="password">Password</label>
        <input id="password" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} />
      </div>
      <div className="wide">
        <button className="btn" type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
        {error && <p className="error" role="alert">{error}</p>}
      </div>
      <p className="wide muted">New here? <Link to="/register">Create an account</Link></p>
    </form>
  );
}
import { useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';

export default function RegisterPage() {
  const { user, register } = useAuth();
  const navigate = useNavigate();
  const [values, setValues] = useState({ name: '', email: '', password: '' });
  const [errors, setErrors] = useState({});
  const [message, setMessage] = useState('');
  const [busy, setBusy] = useState(false);

  if (user) return <Navigate to="/" replace />;
  const change = (e) => setValues((v) => ({ ...v, [e.target.name]: e.target.value }));

  async function submit(e) {
    e.preventDefault();
    setBusy(true);
    setErrors({});
    setMessage('');
    try {
      await register(values.name, values.email, values.password);
      navigate('/', { replace: true });
    } catch (err) {
      setErrors(err.details || {});
      setMessage(err.message);
      setBusy(false);
    }
  }

  return (
    <form className="card form narrow" onSubmit={submit} noValidate>
      <h1 className="wide">Create your account</h1>
      {[
        ['name', 'Name', 'text', 'name'],
        ['email', 'Email', 'email', 'email'],
        ['password', 'Password (8 or more characters)', 'password', 'new-password'],
      ].map(([key, label, type, auto]) => (
        <div className="field wide" key={key}>
          <label htmlFor={key}>{label}</label>
          <input id={key} name={key} type={type} autoComplete={auto} value={values[key]} onChange={change} aria-invalid={Boolean(errors[key])} />
          {errors[key] && <p className="error">{errors[key]}</p>}
        </div>
      ))}
      <div className="wide">
        <button className="btn" type="submit" disabled={busy}>{busy ? 'Creating…' : 'Create account'}</button>
        {message && <p className="error" role="alert">{message}</p>}
      </div>
      <p className="wide muted">Already have an account? <Link to="/login">Sign in</Link></p>
    </form>
  );
}
  • Use the right input types and `autoComplete` values (email, current-password, new-password). Password managers and phone keyboards rely on them, and they make signing in much easier.
  • If someone is already signed in and opens /login, <Navigate to="/" /> sends them to the app.
  • The login page navigates to location.state?.from, then falls back to /.
  • Registration shows the server's field errors (err.details) under each field, using the same pattern as the job form.
  • Both disable the button while waiting, and show failures in a role="alert" paragraph.
import { NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';

export default function Header() {
  const { user, signOut } = useAuth();
  const navigate = useNavigate();
  return (
    <header className="site-header">
      <strong>Job Tracker</strong>
      <nav className="nav">
        {user ? (
          <NavLink to="/" end>My jobs</NavLink>
        ) : (
          <>
            <NavLink to="/login">Sign in</NavLink>
            <NavLink to="/register">Create account</NavLink>
          </>
        )}
      </nav>
      {user && (
        <span className="push muted">
          {user.name}{' · '}
          <button className="link-btn" onClick={() => { signOut(); navigate('/login'); }}>Sign out</button>
        </span>
      )}
    </header>
  );
}

The header now shows either the app link or the sign-in links, and, when signed in, the person's name and a Sign out button. Names come from the server and are drawn through JSX, which escapes them automatically. Never insert user-written text with dangerouslySetInnerHTML: it is the way cross-site scripting happens in React apps.

Your task

  1. Delete any saved jt_token from localStorage (Application tab) and open the app. You should land on the login page.
  2. Create an account on the register page. Try a short password and a repeated email first.
  3. Sign out, then try to open / directly. You are redirected, then returned after signing in.
  4. In the browser tools, change the saved token to garbage and reload. What happens?

How to check it worked

  • Registration and login work and show field errors.
  • A protected page redirects signed-out visitors and returns them after login.
  • A bad or expired token signs the person out.

Summary

  • Restore sessions by asking the server who the saved token belongs to.
  • ProtectedRoute redirects visitors who are not signed in. This is a convenience; the server enforces the rules.
  • A global 401 event signs the person out in one place.

Download the solution (mern-lesson-32.zip)

Frequently asked questions

Where should I store the JWT in the browser?

localStorage is simple and common, but scripts on the page can read it, so any XSS bug could steal it. An httpOnly cookie cannot be read by scripts but needs CSRF protection. Pick one with the trade-off in mind; lesson 40 lists cookies as a next step.

How do I sign out when the token is stateless?

You forget the token on the client. The server cannot end it earlier unless you keep a list of revoked tokens, which most small apps do not need if tokens expire soon.

Is a protected route in React real security?

No. It only hides screens. A person can still call your API directly, so the server must check the token and the owner on every request.