Skip to content
CalliCoder

Spring Boot, Spring Security and JWT with React — Part 4

Published Updated Spring Boot 14 min read Part of Spring Boot JWT Authentication with React

The React client: one request helper so the token header exists in a single place, a route guard that survives a page refresh, optimistic voting with a rollback, and the environment variable that is baked in at build time.

Part 3 finished the polling API. This part is the client, and almost all of its difficulty is in two places: keeping the token in exactly one code path, and handling the window on a page reload where the app does not yet know who the user is.

Written against React 18, React Router 7 and Vite 5.

Scaffolding

The original of this series used create-react-app, which is no longer maintained and no longer recommended by the React documentation. Vite is the direct replacement:

npm create vite@latest polling-app -- --template react
cd polling-app
npm install react-router-dom antd
src/
├── common/        LoadingIndicator, NotFound, ServerError, RequireAuth
├── constants/     index.js  — API base, page size, validation limits
├── poll/          NewPoll, Poll, PollList
├── user/          login/Login, signup/Signup, profile/Profile
├── util/          APIUtils.js
└── app/           App.jsx, AppHeader.jsx

Grouped by feature rather than by kind. components/, containers/ and services/ scatter one change across three directories.

Configuration is baked in at build time

// src/constants/index.js
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api';
export const ACCESS_TOKEN = 'accessToken';
export const POLL_LIST_SIZE = 30;
export const MAX_CHOICES = 6;

import.meta.env.VITE_* under Vite, process.env.REACT_APP_* under CRA. Either way the value is substituted at build time, so one image cannot be promoted from staging to production with a different API host.

The way out is to have no API host: serve the built files from the same origin as the API and use a relative /api base. That removes the build-time coupling and CORS with it.

Never put a secret in one of these. Everything prefixed VITE_ is compiled into the bundle and readable by anyone who opens the file.

One request helper

import { API_BASE_URL, ACCESS_TOKEN } from '../constants';

async function request({ url, method = 'GET', body }) {
  const headers = new Headers({ 'Content-Type': 'application/json' });
  const token = localStorage.getItem(ACCESS_TOKEN);
  if (token) headers.append('Authorization', `Bearer ${token}`);

  const response = await fetch(API_BASE_URL + url, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  if (response.status === 401) {
    localStorage.removeItem(ACCESS_TOKEN);
    window.location.href = '/login';
    return;
  }

  const json = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error(json.message ?? `Request failed (${response.status})`);
  return json;
}

export const login = (body)        => request({ url: '/auth/signin', method: 'POST', body });
export const signup = (body)       => request({ url: '/auth/signup', method: 'POST', body });
export const getCurrentUser = ()   => request({ url: '/user/me' });
export const getAllPolls = (page, size) =>
  request({ url: `/polls?page=${page}&size=${size}` });
export const castVote = (body)     => request({ url: '/polls/vote', method: 'POST', body });

Every call goes through one function, so the Authorization header and the 401 handling exist once. A component calling fetch directly is how an endpoint quietly ends up unauthenticated.

Two details. Bearer needs the trailing space: without it the server sees a malformed header and returns a 401 that reads like an expired token. And .catch(() => ({})) on the JSON parse matters because a 500 from the servlet container returns HTML, and an unguarded response.json() throws a syntax error that hides the real status.

Authentication state and the refresh problem

export default function App() {
  const [currentUser, setCurrentUser] = useState(null);
  const [loading, setLoading] = useState(true);

  const loadCurrentUser = useCallback(async () => {
    setLoading(true);
    try {
      setCurrentUser(await getCurrentUser());
    } catch {
      setCurrentUser(null);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => { loadCurrentUser(); }, [loadCurrentUser]);

  return (
    <Routes>
      <Route path="/login" element={<Login onLogin={loadCurrentUser} />} />
      <Route path="/signup" element={<Signup />} />
      <Route element={<RequireAuth authenticated={!!currentUser} loading={loading} />}>
        <Route path="/" element={<PollList currentUser={currentUser} />} />
        <Route path="/poll/new" element={<NewPoll />} />
        <Route path="/users/:username" element={<Profile />} />
      </Route>
      <Route path="*" element={<NotFound />} />
    </Routes>
  );
}

loading starting at true is the whole point. On a reload the token is in localStorage but the user object is not, and /user/me has not answered yet. A guard that treats that moment as “not authenticated” redirects every logged-in user to the login screen on every refresh:

function RequireAuth({ authenticated, loading }) {
  const location = useLocation();
  if (loading) return <LoadingIndicator />;
  if (!authenticated) return <Navigate to="/login" replace state={{ from: location }} />;
  return <Outlet />;
}

Storing the user object in localStorage alongside the token looks like a shortcut around this and is not one, the cached object goes stale, and a token that the server has since rejected still renders a logged-in interface.

Voting, and rolling back

async function handleVote(pollIndex, choiceId) {
  const previous = polls;
  const poll = polls[pollIndex];

  // optimistic: show the result immediately
  setPolls(polls.map((p, i) => i !== pollIndex ? p : {
    ...p,
    selectedChoice: choiceId,
    totalVotes: p.totalVotes + 1,
    choices: p.choices.map(c =>
      c.id === choiceId ? { ...c, voteCount: c.voteCount + 1 } : c),
  }));

  try {
    const updated = await castVote({ pollId: poll.id, choiceId });
    setPolls(cur => cur.map((p, i) => i === pollIndex ? updated : p));
  } catch (error) {
    setPolls(previous);                       // roll back
    notification.error({ message: error.message });
  }
}

The rollback is the part that gets skipped. Without it, a vote the server rejected (the poll expired, or the unique constraint caught a double vote) leaves the interface showing a vote that does not exist, and it stays wrong until a reload.

Replacing the poll with the server’s response rather than keeping the optimistic guess is also deliberate: the server knows the real counts, including votes cast by other people since the page loaded.

Pagination against the API’s response

const [polls, setPolls] = useState([]);
const [page, setPage] = useState(0);
const [last, setLast] = useState(false);

async function loadMore() {
  const response = await getAllPolls(page, POLL_LIST_SIZE);
  setPolls(cur => [...cur, ...response.content]);
  setLast(response.last);
  setPage(p => p + 1);
}

response.last comes from the API’s PagedResponse and is what the “load more” button reads — comparing polls.length against totalElements also works and drifts as soon as a poll is created while the list is open.

Errors the user can act on

try {
  await signup(values);
  navigate('/login');
} catch (error) {
  notification.error({
    message: 'Polling App',
    description: error.message ?? 'Sorry! Something went wrong. Please try again.',
  });
}

The API’s validation errors carry field names, so a signup form can put “Username is already taken” next to the field rather than in a toast. Ant Design’s Form handles that with validateStatus and help per item; the fallback message exists for the case where the server returned nothing usable.

The header, and knowing who is signed in

function AppHeader({ currentUser, onLogout }) {
  return (
    <Menu mode="horizontal" selectable={false}>
      <Menu.Item key="/"><Link to="/">Polls</Link></Menu.Item>
      {currentUser ? (
        <>
          <Menu.Item key="/poll/new"><Link to="/poll/new">Create Poll</Link></Menu.Item>
          <Menu.Item key="/profile">
            <Link to={`/users/${currentUser.username}`}>{currentUser.name}</Link>
          </Menu.Item>
          <Menu.Item key="/logout" onClick={onLogout}>Logout</Menu.Item>
        </>
      ) : (
        <Menu.Item key="/login"><Link to="/login">Login</Link></Menu.Item>
      )}
    </Menu>
  );
}

onLogout clears the token and resets the state rather than reloading the page:

function logout() {
  localStorage.removeItem(ACCESS_TOKEN);
  setCurrentUser(null);
  navigate('/');
}

Worth stating what that does not do: the JWT stays valid on the server until it expires. Clearing it client-side removes this browser’s copy and nothing else, which is the trade a stateless token makes.

Building it

npm run build          # dist/
npm run preview

The output is static files. Serving them from the same nginx that proxies /api is what removes both CORS and the build-time API host, the arrangement described in the Kubernetes deployment. Client-side routing needs try_files $uri $uri/ /index.html, or a refresh on /poll/new returns a 404.

That completes the series: part 1, part 2, part 3. More in the Spring Boot guides.

Frequently asked questions

Should I still use create-react-app?

No. It is unmaintained and no longer recommended. Vite is the direct replacement and the application code is unchanged.

Why does my app log me out on every refresh?

The route guard treats “current user not yet loaded” as “not authenticated”. Start loading at true and render nothing until /user/me answers.

Can I cache the user object in localStorage to avoid that?

It removes the flash and introduces a worse bug: the cached object goes stale, so a rejected token still renders a logged-in interface. Fetch the user and handle the loading state.

Where should the JWT be stored?

localStorage is simple and readable by any script on the page, so one XSS flaw exposes it. An HttpOnly cookie prevents that and requires CSRF protection. Keep the token short-lived either way.

Why do I get a 401 that looks like an expired token?

Often the header is malformed — Bearer without the trailing space before the token. The server cannot parse it and rejects it the same way.

Why does an error response crash the client?

A 500 from the container returns HTML, and an unguarded response.json() throws a parse error that hides the status. Guard the parse.

Can I change the API URL after building?

No, VITE_* and REACT_APP_* values are substituted at build time. Serve the front end from the same origin as the API and use a relative base instead.

Is it safe to put anything secret in an environment variable?

No. Everything prefixed VITE_ is compiled into the bundle and readable by anyone who opens it.

Why does my optimistic vote stay wrong after an error?

There is no rollback. Keep the previous state, restore it in the catch, and replace the poll with the server’s response on success.

Why does refreshing on /poll/new return 404?

The static server looked for a file that does not exist. Add try_files $uri $uri/ /index.html so the client-side router receives the request.