When building a new B2B SaaS, 90% of the boilerplates and tutorials out there focus exclusively on the JavaScript ecosystem (Next.js, Node, Remix).
But if you want the type safety, multithreading performance (Virtual Threads in Java 21), and enterprise reliability of Spring Boot 3 combined with a fast React 18 frontend, you usually end up spending 3 to 4 weeks configuring the exact same plumbing from scratch:
- Stateless JWT Authentication & Role-Based Access Control (RBAC)
- PostgreSQL persistence with Flyway database migrations
- Stripe Checkout & asynchronous Webhook signature verification
- CORS, security filters, and production deployment configs
In this guide, we’ll walk through the exact production architecture for a Spring Boot 3 (Java 21) + React 18 SaaS in 2026—and look at the core code patterns that hold it all together.
💡 Want to skip the 40+ hours of setup? I packaged this entire production-ready architecture (with full source code, Stripe webhooks, JWT Auth, Flyway migrations, and AI modules) into SaaSKit — The Spring Boot 3 & React 18 SaaS Boilerplate. Check out the live demo and documentation!
1. High-Level Architecture Overview
A modern, decoupled B2B SaaS architecture needs to be stateless, easy to scale horizontally, and strictly typed across boundaries:
- Backend: Java 21 + Spring Boot 3.x (Spring Security 6, Spring Data JPA)
- Database & Migrations: PostgreSQL + Flyway (versioned SQL schemas)
- Frontend: React 18 + TypeScript + Tailwind CSS (hosted on Vercel)
- Payments: Stripe Checkout Sessions + Webhook Event Handler
Let's break down the three hardest parts of the implementation: Database Migrations, Stateless JWT Security, and Stripe Subscription Webhooks.
2. Database Schema & Flyway Migrations (V1__init.sql)
Never use spring.jpa.hibernate.ddl-auto=update in production. One accidental entity change can corrupt customer data or break constraints. Instead, set ddl-auto=validate and let Flyway manage your PostgreSQL schema deterministically.
Place your initial migration inside src/main/resources/db/migration/V1__init_schema.sql:
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'ROLE_USER',
stripe_customer_id VARCHAR(255) UNIQUE,
subscription_status VARCHAR(50) NOT NULL DEFAULT 'FREE',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_stripe_customer ON users(stripe_customer_id);
This guarantees that every environment—from your local Docker container to staging and production—runs the exact same schema version.
3. Stateless JWT Security in Spring Boot 3 (Spring Security 6)
With Spring Security 6, the configuration uses the functional Lambda DSL. Since our React 18 frontend is hosted separately (e.g., on Vercel) and communicates via REST APIs, we disable CSRF for stateless endpoints, configure strict CORS policies, and inject a custom JwtAuthenticationFilter.
Here is the clean SecurityConfig.java pattern:
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/v1/auth/**",
"/api/v1/stripe/webhook",
"/v3/api-docs/**",
"/swagger-ui/**"
).permitAll()
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Notice how /api/v1/stripe/webhook is explicitly whitelisted in permitAll(). Why? Because Stripe servers don't have a user JWT token—they authenticate via cryptographic webhook signatures instead.
4. Handling Stripe Subscriptions & Webhooks Reliably
The biggest mistake developers make when integrating Stripe with React and Spring Boot is updating the user's subscription status on the frontend redirect (?success=true). Users close their browser tabs, lose connectivity, or spoof URLs.
The only source of truth for SaaS billing is the Stripe Webhook.
Here is how to securely verify the Stripe-Signature header and provision access in Spring Boot 3:
@RestController
@RequestMapping("/api/v1/stripe")
@RequiredArgsConstructor
@Slf4j
public class StripeWebhookController {
@Value("${stripe.webhook.secret}")
private String endpointSecret;
private final SubscriptionService subscriptionService;
@PostMapping("/webhook")
public ResponseEntity<String> handleStripeWebhook(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String sigHeader) {
Event event;
try {
event = Webhook.constructEvent(payload, sigHeader, endpointSecret);
} catch (SignatureVerificationException e) {
log.warn("Invalid Stripe webhook signature!");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid signature");
}
switch (event.getType()) {
case "checkout.session.completed" -> {
Session session = (Session) event.getDataObjectDeserializer().getObject().orElseThrow();
subscriptionService.activateSubscription(
session.getClientReferenceId(),
session.getCustomer(),
session.getSubscription()
);
}
case "customer.subscription.deleted" -> {
Subscription sub = (Subscription) event.getDataObjectDeserializer().getObject().orElseThrow();
subscriptionService.cancelSubscription(sub.getCustomer());
}
default -> log.debug("Unhandled Stripe event type: {}", event.getType());
}
return ResponseEntity.ok("Webhook processed");
}
}
When a user clicks "Upgrade to Pro" in your React 18 dashboard:
- React calls
POST /api/v1/billing/checkout-sessionwith the user's JWT. - Spring Boot creates a Stripe Checkout Session passing the user's UUID in
client_reference_id. - Once payment succeeds, Stripe fires
checkout.session.completedto your backend, and Flyway-backed PostgreSQL updatessubscription_status = 'PRO'.
5. Connecting React 18 with Axios Interceptors
On the React 18 side, you can centralize token handling and automatic 401 redirects using an Axios instance:
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
🚀 Skip the Boilerplate & Launch Your SaaS in Days
Building authentication, password resets, Stripe webhook retries, database migrations, responsive UI dashboards, and legal pages from scratch takes weeks away from validating your actual business idea.
If you want this entire stack ready to deploy today, check out SaaSKit:
👉 Explore SaaSKit — Spring Boot 3 & React 18 SaaS Boilerplate
What you get out of the box:
- ✅ Full-Stack Production Code: Java 21, Spring Boot 3, and React 18 (Dark Mode UI)
- ✅ Complete Security: JWT Authentication, Role-Based Access Control & Protected Routes
- ✅ Stripe Billing Ready: Pre-built Checkout, Customer Portal & Webhook handlers
- ✅ Database & Migrations: PostgreSQL + Flyway pre-configured
- ✅ Step-by-Step Guide: Includes the full
SaaSKit-Documentation-Guide.pdffor quickstart and cloud deployment - ✅ Two Tiers Available: Standard License (€39) and Pro & AI License (€59)
What are you building with Spring Boot and React this year? Let me know in the comments below! 👇
Top comments (0)