Post

React + Spring Boot — Full-Stack App with JWT Authentication

Build a complete full-stack application with React frontend and Spring Boot backend. Covers JWT authentication, CORS configuration, Axios interceptors, protected routes, and the development workflow for serving both from one project.

React + Spring Boot — Full-Stack App with JWT Authentication

The Problem — Backend Devs Need to Ship UIs

You’ve built a REST API with Spring Boot. Now someone needs a UI for it. The options:

  1. Hire a frontend developer (budget says no)
  2. Build it yourself with React (but you’re a Java developer)
  3. Ship a full-stack app from one project

Option 3 is what we’re doing today. A Spring Boot backend with JWT authentication and a React frontend that talks to it. Both run from the same repository, deploy together, and work seamlessly.


Architecture

graph LR
    A[React Frontend<br/>Port 5173] -->|REST API calls<br/>with JWT| B[Spring Boot Backend<br/>Port 8080]
    B --> C[H2 Database]
    B --> D[JWT Token Validation]
    A --> E[LocalStorage<br/>JWT Token]

diagram

The React app calls the Spring Boot API over HTTP. Authentication happens via JWT tokens stored in localStorage. In production, you’d serve the React build as static files from Spring Boot itself.


Backend Setup

Dependencies

1
2
3
4
5
6
7
8
9
10
11
12
13
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>

JWT Utility

The JwtUtil class handles token generation and validation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Component
public class JwtUtil {

    private final SecretKey key;
    private final long expirationHours;

    public JwtUtil(@Value("${app.jwt.secret}") String secret,
                   @Value("${app.jwt.expiration-hours:24}") long expirationHours) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expirationHours = expirationHours;
    }

    public String generateToken(String username) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(username)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plus(expirationHours, ChronoUnit.HOURS)))
                .signWith(key)
                .compact();
    }

    public boolean isTokenValid(String token) {
        try {
            Claims claims = extractClaims(token);
            return claims.getExpiration().after(Date.from(Instant.now()));
        } catch (Exception e) {
            return false;
        }
    }
}

Security Configuration — Stateless

The key: no sessions. Every request must carry its JWT:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/api/auth/**").permitAll()
                    .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter(),
                UsernamePasswordAuthenticationFilter.class)
            .build();
}

The JWT filter extracts the token from the Authorization header and sets the Spring Security context:

1
2
3
4
5
6
7
8
9
10
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
    String token = authHeader.substring(7);
    if (jwtUtil.isTokenValid(token)) {
        String username = jwtUtil.extractUsername(token);
        UsernamePasswordAuthenticationToken auth =
            new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
        SecurityContextHolder.getContext().setAuthentication(auth);
    }
}

Frontend Setup

We use React 18 with Vite (faster than Create React App):

1
2
3
4
5
6
7
8
{
  "dependencies": {
    "axios": "^1.7.2",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-router-dom": "^6.23.1"
  }
}

Axios Instance with Interceptors

The core pattern: a configured Axios instance that automatically attaches the JWT to every request:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import axios from 'axios';

const api = axios.create({
  baseURL: 'http://localhost:8080',
  headers: { 'Content-Type': 'application/json' },
});

// Attach token to every request
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Redirect to login on 401
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default api;

Login Flow

sequenceDiagram
    participant User
    participant React
    participant API as Spring Boot API
    participant JWT as JWT Utility

    User->>React: Enter credentials
    React->>API: POST /api/auth/login {username, password}
    API->>JWT: Authenticate & generate token
    JWT-->>API: eyJhbGciOiJIUzI1NiJ9...
    API-->>React: {token, username, type: "Bearer"}
    React->>React: Store token in localStorage
    React->>React: Redirect to /products
    React->>API: GET /api/products (Authorization: Bearer ...)
    API->>JWT: Validate token
    JWT-->>API: Valid, user = "admin"
    API-->>React: [products...]

diagram

The login component:

1
2
3
4
5
6
7
8
9
10
11
const handleSubmit = async (e) => {
  e.preventDefault();
  try {
    const response = await api.post('/api/auth/login', { username, password });
    localStorage.setItem('token', response.data.token);
    localStorage.setItem('username', response.data.username);
    navigate('/products');
  } catch (err) {
    setError(err.response?.data?.error || 'Login failed');
  }
};

Protected Routes

React Router handles client-side route protection:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function PrivateRoute({ children }) {
  const token = localStorage.getItem('token');
  return token ? children : <Navigate to="/login" />;
}

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/login" element={<Login />} />
        <Route
          path="/products"
          element={
            <PrivateRoute>
              <ProductList />
            </PrivateRoute>
          }
        />
      </Routes>
    </BrowserRouter>
  );
}

This is client-side only. The real security is on the backend — a missing or invalid JWT returns 401.


CORS Configuration

The most common pain point. React runs on port 5173, Spring Boot on 8080. Without CORS configuration, the browser blocks cross-origin requests.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of(
        "http://localhost:3000",
        "http://localhost:5173"
    ));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);
    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}

Key points:

  • allowedOrigins must include your React dev server URL
  • allowedHeaders: * permits the Authorization header
  • allowCredentials: true allows cookies (if you use them later)
  • maxAge caches the preflight OPTIONS response for 1 hour

Development Workflow

During Development (Two Servers)

1
2
3
4
5
# Terminal 1 — Backend
cd backend && mvn spring-boot:run   # Port 8080

# Terminal 2 — Frontend
cd frontend && npm run dev          # Port 5173 with proxy to 8080

Vite proxies /api calls to http://localhost:8080, so during development you avoid CORS issues entirely:

1
2
3
4
5
6
// vite.config.js
server: {
  proxy: {
    '/api': { target: 'http://localhost:8080', changeOrigin: true }
  }
}

In Production (One Server)

Build React, copy to Spring Boot’s static directory:

1
2
cd frontend && npm run build
cp -r dist/* ../backend/src/main/resources/static/

Spring Boot serves the React SPA and the API from the same origin — no CORS needed.


Common Problems

ProblemCauseSolution
CORS “No Access-Control-Allow-Origin”Missing CORS configAdd CorsConfigurationSource bean
403 on POST/PUT/DELETECSRF enabled (default)Disable CSRF for stateless API: .csrf(csrf -> csrf.disable())
401 on login endpointLogin path not permittedAdd .requestMatchers("/api/auth/**").permitAll()
Token not sent after loginAxios instance not usedImport the configured api instance, not raw axios
React routes return 404 on refreshServer doesn’t know about client routesServe index.html for unknown paths (SPA fallback)
JWT expiredToken TTL too shortIncrease expiration-hours or implement refresh tokens
H2 console 403Frame options blockedAdd .headers(h -> h.frameOptions(f -> f.disable()))

Full Working Example

The complete project with both backend and frontend:

https://github.com/AnupamSinha/spring-boot-examples/tree/main/30-react-fullstack

1
2
3
4
5
6
7
8
# Run backend
cd 30-react-fullstack/backend
mvn spring-boot:run

# Run frontend (separate terminal)
cd 30-react-fullstack/frontend
npm install
npm run dev

Login with admin / admin123 and you’ll see the products page with data from the Spring Boot API.


References

This post is licensed under CC BY 4.0 by the author.