React/Next.js × Spring Boot: CORS, State, Patterns & Tests that Just Work
Frontend ↔ Backend without the drama: React/Next.js + Spring Boot that actually talk to each other
You’ve seen it: frontend on :3000, backend on :8080, both “running,” click Ping API… boom—CORS error, odd JSON, or timeouts. This post walks through the practical pieces that make the two sides play nicely: CORS, state, patterns, and tests that prove the wiring works.
🚫 CORS, but make it predictable
Browsers enforce the Same-Origin Policy. Cross-site calls need CORS headers from your backend (not the frontend). Two reliable dev→prod approaches:
Option A — Configure CORS in Spring Security (WebFlux) (works great with tokens/cookies, and keeps rules in one place)
// src/main/java/.../SecurityConfig.java
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain security(ServerHttpSecurity http) {
return http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()) // keep disabled if you're not using cookies
.authorizeExchange(reg -> reg.anyExchange().permitAll())
.build();
}
@Bean
public CorsConfigurationSource corsSource() {
CorsConfiguration c = new CorsConfiguration();
c.setAllowedOrigins(List.of("http://localhost:3000")); // prod: your real UI origin
c.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
c.setAllowedHeaders(List.of("*"));
c.setAllowCredentials(true); // never combine with "*" origins
UrlBasedCorsConfigurationSource s = new UrlBasedCorsConfigurationSource();
s.registerCorsConfiguration("/api/**", c);
return s;
}
}
Option B — Dev proxy in Next.js (bypass CORS during dev) Keep production strict (Option A), but in dev, proxy /api/* to the backend:
// frontend/next.config.js
const nextConfig = {
async rewrites() {
return [{ source: "/api/:path*", destination: "http://localhost:8080/api/:path*" }];
},
};
module.exports = nextConfig;
Now in React you can safely call fetch("/api/ping") while developing—no browser CORS preflight at all.
Rule of thumb: Prod = backend CORS, Dev = proxy. Don’t sprinkle “disable CORS” plugins or serverless shims everywhere.
🔁 State that doesn’t fight you (React)
Start simple. Local UI state covers most integration screens. Add global state only when you truly share data across distant components.
// frontend/app/page.tsx (or any component)
import { useState } from "react";
export default function PingCard() {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<string>("");
const [error, setError] = useState<string | null>(null);
async function ping() {
setLoading(true); setError(null);
try {
const res = await fetch("/api/ping"); // uses Next.js dev proxy
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setResult(`status: ${json.status}`);
} catch (e) {
setError("Backend connection failed");
setResult("");
} finally {
setLoading(false);
}
}
return (
<div>
<button onClick={ping} disabled={loading}>
{loading ? "Checking..." : "Ping API"}
</button>
{error && <p style={{ color: "crimson" }}>{error}</p>}
{result && <p style={{ color: "seagreen" }}>{result}</p>}
</div>
);
}
Tip: Promises can hang. Add a tiny AbortController wrapper or a 8–10s timeout for friendlier UX.
🏗️ Integration patterns that scale
1) Environment-aware endpoints (no hardcoded URLs)
// frontend/lib/apiBase.ts
export const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; // "" means "use dev proxy"
// .env.local (dev)
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
2) Predictable backend responses (contract first)
@GetMapping("/api/ping")
public Mono<Map<String, Object>> ping() {
return Mono.just(Map.of(
"status", "ok",
"timestamp", Instant.now().toString(),
"service", "codeforge-backend"
));
}
3) One fetch helper to rule them all
// frontend/lib/jsonFetch.ts
export async function jsonFetch(input: RequestInfo, init?: RequestInit) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 8000);
try {
const res = await fetch(input, { ...init, signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(t);
}
}
4) API-Gateway friendly URL shape Keep backend routes under /api/**. It makes gateways, auth filters, and observability consistent.
🧪 Testing the wire: L0, L1, L2
Testing is your insurance policy that “it works on my machine” becomes “it works for users.”
Recommended by LinkedIn
L0 — App boots & beans wire up (fastest):
// src/test/java/.../L0_ContextLoadsTest.java
@SpringBootTest
class L0_ContextLoadsTest {
@Test void contextLoads() {}
}
L1 — API contract (Cucumber + REST-assured or WebTestClient):
# src/test/resources/features/ping.feature
Feature: API Health
Scenario: Ping returns ok
When I GET "/api/ping"
Then the response status should be 200
And the JSON at "$.status" should be "ok"
Step definition (REST-assured example):
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
@When("I GET {string}")
public void i_get(String path) {
lastResponse = given().when().get(path);
}
@Then("the JSON at {string} should be {string}")
public void the_json_at_should_be(String jsonPath, String expected) {
lastResponse.then().body(jsonPath, equalTo(expected));
}
L2 — Full user journey (Cypress/Playwright):
// cypress/e2e/ping.cy.ts
it("shows status ok after clicking Ping", () => {
cy.visit("http://localhost:3000");
cy.contains("Ping API").click();
cy.contains("status: ok").should("be.visible");
});
Coverage strategy: L0 catches wiring regressions, L1 catches contract drift, L2 catches integration and timing issues (like CORS, cookies, proxies).
🏷️ Naming that shapes your design
Names guide thinking. Compare:
// Narrow thinking
const createUser = () => {/* basic CRUD */};
// System thinking
const provisionUserAccount = () => {
/* validation, idempotency, audit, side-effects */
};
Choosing verbs like provision, issueToken, recordEvent nudges you to design for idempotency, retries, and observability—habits that pay off when you scale.
🧰 Small checklist before you run
🚀 Try It Yourself
# Clone & run backend
git clone https://github.com/ganmath/CodeForge.git
cd CodeForge/backend
./mvnw spring-boot:run
# Run structured tests
./mvnw test -Dtest="L0_ContextLoadsTest,RunL1CucumberTest"
Open a second terminal for the frontend:
cd ../frontend
npm install
npm run dev
Visit http://localhost:3000 and click Ping API.
💬 Your turn
What tripped you up most the first time you wired React to Spring Boot—CORS, cookies, or inconsistent JSON? Share the symptom and your current workaround; I’ll suggest the cleanest fix.
#FullStackDevelopment #SpringBoot #React #NextJS #CORS #Testing #Architecture #Java #WebDevelopment #CodeForge