Spring Boot + React Full Stack: Complete Development and Deployment Guide

A hands-on guide to building and deploying a production-ready full stack application with Spring Boot, JPA, Spring Security JWT, and React. Covers project structure, CORS, Bean Validation, Docker Compose, and VPS deployment — everything you need in 2026.

Spring Boot + React Full Stack: Complete Development and Deployment Guide

By Elhassane Mehdioui, Full Stack Web Developer — June 2026


Building a production-grade full stack application with Spring Boot and React is one of the most in-demand skill sets for Java developers today. In this Spring Boot React tutorial you will go from an empty folder to a Dockerised application running on a VPS, covering every layer: project structure, JPA repositories, REST controllers, Spring Security with JWT, a React frontend consuming the API via Axios, CORS configuration, Bean Validation, Docker Compose for local development, and a repeatable VPS deployment strategy.

Whether you are starting a new project or trying to understand how the pieces fit together, this Java React full stack guide has you covered.


1. Project Structure

Keeping the backend and frontend as siblings in a mono-repo is the cleanest approach for small-to-medium teams.

my-app/
├── backend/                  # Spring Boot Maven project
│   ├── src/main/java/com/myapp/
│   │   ├── config/
│   │   ├── controller/
│   │   ├── dto/
│   │   ├── entity/
│   │   ├── repository/
│   │   ├── security/
│   │   └── service/
│   ├── src/main/resources/
│   │   └── application.yml
│   └── pom.xml
├── frontend/                 # React (Vite) project
│   ├── src/
│   │   ├── api/
│   │   ├── components/
│   │   ├── pages/
│   │   └── main.jsx
│   └── package.json
└── docker-compose.yml

Keep the backend and frontend directories completely independent. The frontend talks to the backend exclusively through HTTP — no shared code, no Maven plugins that copy static files into the JAR (that pattern creates unnecessary coupling and slows builds).


2. Spring Boot Backend with JPA and REST

2.1 Dependencies (pom.xml)

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.5</version>
  </dependency>
  <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
  </dependency>
</dependencies>

2.2 Entity and Repository

// entity/Product.java
@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Name is required")
    @Size(max = 120)
    private String name;

    @NotNull
    @DecimalMin("0.0")
    private BigDecimal price;

    // getters / setters omitted for brevity
}
// repository/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByNameContainingIgnoreCase(String name);
}

2.3 REST Controller with Bean Validation

// controller/ProductController.java
@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductRepository repo;

    public ProductController(ProductRepository repo) {
        this.repo = repo;
    }

    @GetMapping
    public List<Product> list() {
        return repo.findAll();
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Product create(@Valid @RequestBody Product product) {
        return repo.save(product);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        repo.deleteById(id);
    }
}

The @Valid annotation triggers Bean Validation. Any constraint violation is translated into a 400 Bad Request automatically when you add a @RestControllerAdvice that handles MethodArgumentNotValidException.


3. Spring Security with JWT

3.1 JWT Utility

// security/JwtUtil.java
@Component
public class JwtUtil {

    @Value("${app.jwt.secret}")
    private String secret;

    private static final long EXPIRATION_MS = 86_400_000L; // 24 h

    public String generate(String username) {
        return Jwts.builder()
                .subject(username)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
                .signWith(Keys.hmacShaKeyFor(secret.getBytes()))
                .compact();
    }

    public String extractUsername(String token) {
        return Jwts.parser()
                .verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();
    }
}

3.2 Security Filter Chain

// config/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtFilter jwtFilter;

    public SecurityConfig(JwtFilter jwtFilter) {
        this.jwtFilter = jwtFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

The JWT filter reads the Authorization: Bearer <token> header, validates the token, and sets the SecurityContextHolder — the standard stateless pattern for REST APIs.


4. CORS Configuration

CORS must be configured on the backend so that the browser accepts responses from a different origin (e.g., http://localhost:5173 during development).

// config/CorsConfig.java
@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of(
            "http://localhost:5173",
            "https://your-production-domain.com"
        ));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true);

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

Then register this source inside your SecurityFilterChain:

.cors(cors -> cors.configurationSource(corsConfigurationSource))

A common mistake is configuring CORS only with @CrossOrigin on controllers and forgetting that Spring Security processes requests before controllers — always configure CORS at the security layer.


5. React Frontend with Axios

5.1 Axios Instance

// src/api/axiosClient.js
import axios from 'axios';

const client = axios.create({
  baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080',
  headers: { 'Content-Type': 'application/json' },
});

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

client.interceptors.response.use(
  (res) => res,
  (err) => {
    if (err.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    return Promise.reject(err);
  }
);

export default client;

5.2 Auth Hook

// src/hooks/useAuth.js
import { useState } from 'react';
import client from '../api/axiosClient';

export function useAuth() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  async function login(username, password) {
    setLoading(true);
    try {
      const { data } = await client.post('/api/auth/login', { username, password });
      localStorage.setItem('token', data.token);
    } catch (e) {
      setError(e.response?.data?.message ?? 'Login failed');
    } finally {
      setLoading(false);
    }
  }

  return { login, loading, error };
}

5.3 Product List Component

// src/pages/Products.jsx
import { useEffect, useState } from 'react';
import client from '../api/axiosClient';

export default function Products() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    client.get('/api/products').then(({ data }) => setProducts(data));
  }, []);

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name} — ${p.price}</li>
      ))}
    </ul>
  );
}

6. Docker Compose for Local Development

# docker-compose.yml
version: '3.9'

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  backend:
    build: ./backend
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/myapp
      SPRING_DATASOURCE_USERNAME: myapp
      SPRING_DATASOURCE_PASSWORD: secret
      APP_JWT_SECRET: change-me-in-production-use-256-bits
    ports:
      - "8080:8080"
    depends_on:
      - db

  frontend:
    build: ./frontend
    environment:
      VITE_API_URL: http://localhost:8080
    ports:
      - "5173:5173"

volumes:
  pgdata:

Add a minimal Dockerfile to each service. For the backend:

# backend/Dockerfile
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests

FROM eclipse-temurin:21-jre-alpine
COPY --from=build /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

For the frontend:

# frontend/Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]

Run everything with docker compose up --build.


7. VPS Deployment

The deployment strategy below works on any Ubuntu 22.04+ VPS (DigitalOcean, Hetzner, Vultr, etc.).

7.1 Install Docker on the VPS

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER

7.2 Build and Push Images to a Registry

# From your CI/CD pipeline or locally
docker build -t ghcr.io/youruser/myapp-backend:latest ./backend
docker build -t ghcr.io/youruser/myapp-frontend:latest ./frontend
docker push ghcr.io/youruser/myapp-backend:latest
docker push ghcr.io/youruser/myapp-frontend:latest

7.3 Production docker-compose.prod.yml

version: '3.9'

services:
  db:
    image: postgres:16-alpine
    env_file: .env.prod
    volumes:
      - pgdata:/var/lib/postgresql/data
    restart: unless-stopped

  backend:
    image: ghcr.io/youruser/myapp-backend:latest
    env_file: .env.prod
    restart: unless-stopped
    depends_on:
      - db

  frontend:
    image: ghcr.io/youruser/myapp-frontend:latest
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on:
      - backend
      - frontend
    restart: unless-stopped

volumes:
  pgdata:

7.4 Nginx Reverse Proxy Snippet

server {
    listen 443 ssl;
    server_name your-production-domain.com;

    ssl_certificate     /etc/letsencrypt/live/your-production-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-production-domain.com/privkey.pem;

    location /api/ {
        proxy_pass http://backend:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location / {
        proxy_pass http://frontend:5173;
        proxy_set_header Host $host;
    }
}

Use Certbot (certbot --nginx) to obtain a free TLS certificate from Let's Encrypt before starting Nginx.

7.5 Deploy Command on the VPS

docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d

A zero-downtime approach is to use docker compose up -d --no-deps --build backend per service, or switch to Docker Swarm / Kubernetes once traffic warrants it.


8. Key Takeaways

ConcernSolution
AuthenticationSpring Security stateless filter chain + JJWT
Input validation@Valid + Jakarta Bean Validation constraints
Cross-origin requestsCorsConfigurationSource registered in the security chain
Local dev parityDocker Compose with named volumes
Production deliveryMulti-stage Dockerfiles + private registry + Nginx TLS proxy

This Spring Boot frontend guide 2026 covers the complete journey from a blank workspace to a live, secured application. The same skeleton scales to microservices — just split the backend module, add a gateway, and update the Nginx upstreams.


About the Author

Elhassane Mehdioui is a Full Stack Web Developer specialising in Java ecosystems and modern React architectures. He writes practical guides focused on real-world production concerns rather than toy examples.

Need the full picture?

Full-stack applications from database to UI.

MERN stack, Laravel + React, Spring Boot — complete solutions built end-to-end.

HassanOSSYS-00
SECURE CHANNEL · ACTIVE
INIT://