React/Next.js × Spring Boot: CORS, State, Patterns & Tests that Just Work

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.”

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

  • Backend routes live under /api/**.
  • If you need cookies: set allowCredentials(true) and a specific allowedOrigins list.
  • In dev, prefer the Next.js rewrite proxy; in prod, let Spring emit CORS headers.
  • Time-box network calls in the frontend; show loading & error states.
  • Keep a single jsonFetch helper and reuse it.


🚀 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

To view or add a comment, sign in

More articles by Ganesh Bhat

Others also viewed

Explore content categories