Spring BootとReactの連携は、Spring BootがJSONを返すREST APIを公開し、ReactがそれをHTTPで呼び出す形で組みます。つまずくのはコードを書く量ではなく、CORS、認証、本番でのSPA配信という3つの境目です。この記事では、Spring Boot 4.1.1・Spring Security 7.1.1・Vite 8.3.4で実際に動かしたコードと、curlで確かめた応答をもとに、プロジェクト作成から1つのjarへの同梱までを順に解説します。
まとめ:Spring BootとReactを連携する5つの手順
- 役割分担:Spring Bootは「JSONを返すAPIサーバー」、Reactは「APIを呼んで画面を描くクライアント」です。両者はHTTPでつながり、コードは共有しません。
- 手順1:Spring Initializrで「Spring Web」だけを選び、
@RestControllerでJSONを返すAPIを作ります。Spring Boot 4ではこの依存がspring-boot-starter-webmvcとして生成されます。 - 手順2:ViteでReactプロジェクトを作り、
fetchでAPIを呼びます。URLを/api/...の相対パスにしておくと、開発時のViteプロキシとjar同梱の両方で書き換えが要りません。 - 手順3:開発中はViteのプロキシでCORSを発生させず、別オリジンで運用するときはSpring Boot側で許可します。Spring MVC側でCORSを設定してSecurityFilterChainを自作する構成では、
http.cors()を呼ばないと認証必須APIへのプリフライトが401になります。 - 手順4:Spring Securityの依存を追加し、JWTは
spring-boot-starter-security-oauth2-resource-serverで検証し、ログインAPIでJwtEncoderを使って発行します。自前の検証フィルタは不要です。 - 手順5:本番はViteの出力先を
src/main/resources/staticにして1つのjarにまとめ、React Routerの直リンクをindex.htmlへフォールバックさせます。
Spring BootとReactの違いと役割分担
Spring BootはJavaでサーバー側の処理を書くフレームワークで、データベースへのアクセス、業務ロジック、認証を担います。Reactはブラウザで動くUIライブラリで、画面の描画と操作への反応を担います。動く場所も言語も違うため「どちらを選ぶか」の関係ではなく、1つのWebシステムの前後を分担する組み合わせです。ReactはJavaではなくJavaScriptのライブラリです。
APIを境界にしておくと、画面をReactからスマホアプリに差し替えても、同じAPIをそのまま使えます。Javaのバックエンドと、TypeScriptで書くフロントエンドの分担をもう少し広く整理したい場合は、Javaバックエンド開発の構成|Spring Boot 4.1+GradleとTypeScriptフロントエンドの役割分担も参考になります。
検証環境とディレクトリ構成
この記事のコードは、次の環境で実際にビルドして動かしています(2026年10月11日)。Spring Bootの版とサポート期限はSpring Bootのプロジェクトページに掲載されている内容と同じで、現行は4.1.1です。
| 項目 | 版 | 補足 |
|---|---|---|
| Spring Boot | 4.1.1 | OSSサポートは2027-07-31まで |
| Spring Security | 7.1.1 | Boot 4.1.1のBOMが指定 |
| Spring Framework | 7.0.9 | 同上 |
| Java | 25 | Boot 4は17以上 |
| Vite | 8.3.4 | Node 20.19.0以上21未満か22.12.0以上 |
| React | 19系 | create-viteのテンプレートは ^19.2.8 |
Spring Boot 3.5は2026年6月30日にOSSサポートが終了しました。これから作るなら4.1系を選びます。各系列の期限はSpring Bootバージョン一覧とサポート期限【2026年8月】最新4.1と3.5 EOL後の選び方に、4系で変わった点はSpring Boot 4とは?最新バージョン4.1の変更点・新機能とSpring Boot 3との違いを解説【2026年最新】にまとめています。
ディレクトリは、1つのリポジトリにバックエンドとフロントエンドを並べる構成にします。
my-app/
├── backend/ # Spring Boot(Maven)
└── frontend/ # React(Vite)
手順1:Spring BootでのREST API実装
Spring Initializrで選ぶ依存関係
Spring Initializrで、Spring Boot 4.1.1・Java 25(17以上なら可)を選び、Artifactを backend、Package nameを com.example.backend にして、依存関係に「Spring Web」を追加します。この記事のJavaコードはこのパッケージを前提にしています。Spring Securityを先に入れると既定の設定で全URLが認証必須になり、手順1〜3の動作確認ができないので、認証の依存は手順4で追加します。生成された pom.xml の依存は次のとおりでした。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
Spring Boot 3系の記事で見かける spring-boot-starter-web は、4系では非推奨となり(成果物は残っています)、spring-boot-starter-webmvc が推奨になりました。古い記事の依存をそのまま写すとInitializrの生成物と食い違うので、starter名は生成された pom.xml を正としてください。
JSONを返すコントローラーの実装
@RestControllerを付けたクラスのメソッドが返したオブジェクトは、JSONに変換されてレスポンスになります。ここでは名前を受け取って挨拶を返すAPIを、認証なしで呼べる /api/public の下に置きます。
package com.example.backend;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/public")
public class GreetingController {
public record GreetingResponse(String message) {}
@GetMapping("/greet")
public GreetingResponse greet(@RequestParam String name) {
return new GreetingResponse("Hello, " + name + "!");
}
}
./mvnw spring-boot:run で起動し、curl "http://localhost:8080/api/public/greet?name=React" を実行すると、Content-Type: application/json で {"message":"Hello, React!"} が返りました。アノテーションの役割はSpring Bootのアノテーション一覧|役割別の早見表とBoot 4での変更点で一覧にしています。
手順2:ViteでのReact環境構築とAPI呼び出し
create-viteでのReactプロジェクト作成
Reactの雛形はViteで作ります。以前の定番だったCreate React Appは、React公式が2025年2月14日に新規利用の非推奨を告知しています。Vite 8には、Node.jsの20.19.0以上21未満、または22.12.0以上が必要です(Viteの package.json の engines は ^20.19.0 || >=22.12.0)。検証時の create-vite は9.2.1でした。
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm run dev
開発サーバーの既定は http://localhost:5173 です。5173番が使用中だと次の空き番号で起動し、手順3で許可するオリジンと食い違うので、起動ログのURLを確認してください。Viteの設定項目やCreate React Appからの移行はViteとは?Reactの環境構築手順とVite 8の変更点・create-react-appからの移行で詳しく扱っています。
fetchでのAPI呼び出しと応答の表示
src/App.jsx を次のように書き換えます。初回表示時に useEffect でAPIを呼び、結果を useState に入れて描画します。
import { useEffect, useState } from 'react'
function App() {
const [greeting, setGreeting] = useState('')
const [error, setError] = useState(null)
useEffect(() => {
const controller = new AbortController()
fetch('/api/public/greet?name=React', { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then((data) => setGreeting(data.message))
.catch((err) => {
if (err.name !== 'AbortError') setError(err.message)
})
return () => controller.abort()
}, [])
if (error) return <p>取得に失敗しました: {error}</p>
return <h1>{greeting || '読み込み中…'}</h1>
}
export default App
ポイントは3つあります。URLを http://localhost:8080/... ではなく /api/... の相対パスにしていること、fetchはHTTP 404や500でも例外を投げないので res.ok を自分で確かめていること、そして AbortController でアンマウント時にリクエストを止めていることです。create-viteの雛形は StrictMode で包まれており、開発モードでは副作用が「実行→後片付け→再実行」と2回走るため、fetch も2回呼ばれます。中断処理を入れておくと、1回目は後片付けの時点で中断され、2回目の応答だけが画面に反映されます。
fetchとaxiosの使い分け
HTTPクライアントは標準の fetch で足ります。axios(npmの最新は1.20.0)は、共通ヘッダーやエラー処理をインスタンスにまとめたいときに入れます。
| 項目 | fetch | axios |
|---|---|---|
| 導入 | ブラウザ標準 | npm install axios |
| JSON変換 | res.json() を呼ぶ | 自動 |
| 4xx・5xx | 例外にならない | 例外になる |
| 共通ヘッダー | 関数で包む | axios.create で共有 |
手順3:CORSエラーの解消と許可オリジンの設定
Reactを5173番、Spring Bootを8080番で動かすと、ポートが違うだけで別オリジンとして扱われます。ブラウザは別オリジンへのリクエストの応答を、サーバーが Access-Control-Allow-Origin で許可しない限りJavaScriptに渡しません。これがCORSエラーです。オリジンの判定条件は同一オリジンとは?判定条件とSame-Origin Policyが止めるもの・止めないもの、プリフライトの仕組みはCORSとは?仕組み・プリフライト・サーバー設定例・エラー解決を実装目線で解説で解説しています。
開発時:Viteのプロキシによる同一オリジン化
開発中は、Viteの開発サーバーに /api 宛てのリクエストを中継させます。ブラウザから見ると5173番にしかアクセスしていないため、CORSそのものが起きません。vite.config.js に server.proxy を追加します。
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
})
この設定で http://localhost:5173/api/public/greet?name=Vite にアクセスすると、Spring Bootの応答 {"message":"Hello, Vite!"} が返りました。手順2でURLを相対パスにしたのは、このプロキシを通すためです。
別オリジン運用:Spring Boot側のCORS設定
本番でフロントエンドとAPIを別ドメインに置く場合、相対パスのままではリクエストがフロントエンドの配信先へ飛ぶので、React側はAPIの絶対URLを使います。Viteでは VITE_ で始まる環境変数が import.meta.env から読めるため、fetch(`${import.meta.env.VITE_API_BASE_URL}/api/public/greet?name=React`) のようにビルド時に差し込みます。そのうえでSpring Boot側で許可するオリジンを明示します。WebMvcConfigurerでまとめて設定する例です。
package com.example.backend;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// 本番では実際のフロントエンドのオリジンに置き換える
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type");
}
}
allowedOrigins("*") は allowCredentials(true) と同時に使えません。この記事のJWT方式はトークンを Authorization ヘッダーで送り、Cookieを使わないため、allowCredentials は設定していません。
Spring Security導入後のプリフライト401とhttp.cors()
上の WebMvcConfigurer でCORSを設定し、Spring Securityの SecurityFilterChain を自分で定義する構成では、「CORSは設定したのに、ログインが要るAPIだけCORSエラーになる」という症状が出ます。原因はリクエストの順番です。ブラウザは別オリジンへ Authorization ヘッダー付きのリクエストを送る前に、OPTIONSメソッドのプリフライトを送ります。プリフライトは送る予定のヘッダー名を Access-Control-Request-Headers で伝えるだけで、Authorization の値は含みません。Spring Securityのフィルタは WebMvcConfigurer のCORS設定より先に動くので、何もしないとプリフライトを認証情報のないリクエストとして拒否します。許可された応答には Access-Control-Max-Age: 1800 が付いていたので、ブラウザはその後30分間、同じ条件のプリフライトを省略します。
手元で、上の CorsConfig を置いたまま SecurityFilterChain の .cors(...) の有無だけを変えて、保護対象の /api/me へプリフライトを送った結果です。
| 条件 | 応答 |
|---|---|
| http.cors() なし・Origin 5173 | 401 |
| http.cors() あり・Origin 5173 | 200(許可ヘッダー付き) |
| http.cors() あり・未許可のOrigin | 403「Invalid CORS request」 |
| http.cors() なし・公開APIへのGET | 200(許可ヘッダー付き) |
最後の行が混乱のもとです。公開APIは単純なGETなのでプリフライトが飛ばず、Spring MVCのCORS設定だけで通ります。動くAPIと動かないAPIが混在するため、CORS設定の書き間違いを疑いがちですが、直すべきは SecurityFilterChain です。Spring SecurityのCORSのドキュメントにあるとおり、http.cors(Customizer.withDefaults()) を呼ぶと、Spring MVC側のCORS設定がSecurityのフィルタでも使われます。設定は次の手順4のコードに入れています。
手順4:Spring Security 7でのJWT認証の実装
ログインが必要なAPIは、ログイン時に発行したJWTを Authorization: Bearer ... ヘッダーで受け取り、署名と有効期限を検証して通します。JWTの検証はOAuth2 Resource Serverの機能で行えるため、トークンを解析する自前のフィルタを書く必要はありません。まず pom.xml に次の2つを追加します(Initializrでは「Spring Security」と「OAuth2 Resource Server」)。4系のstarter名は、3系の spring-boot-starter-oauth2-resource-server から変わっています。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
Spring Securityのフィルタの並びはSecurityFilterChainとは?Spring Securityのフィルタ実行順序と設定例【Spring Security 7対応】で解説しています。
SecurityFilterChainでのJWT検証の設定
鍵は1つの秘密鍵で署名と検証を行うHS256を使います。RFC 7518はHS256の鍵にハッシュ出力と同じ256ビット以上を求めているので、application.properties に直接書かず、32バイトの乱数を環境変数で渡します。app.jwt.secret は環境変数 APP_JWT_SECRET からも読めます。
export APP_JWT_SECRET="$(openssl rand -base64 32)"
./mvnw spring-boot:run
package com.example.backend;
import java.util.Base64;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/api/auth/login").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
// 環境変数 APP_JWT_SECRET(Base64・32バイト以上)から読む
@Bean
SecretKey jwtSecretKey(@Value("${app.jwt.secret}") String base64) {
return new SecretKeySpec(Base64.getDecoder().decode(base64), "HmacSHA256");
}
@Bean
JwtDecoder jwtDecoder(SecretKey key) {
return NimbusJwtDecoder.withSecretKey(key).build();
}
@Bean
JwtEncoder jwtEncoder(SecretKey key) {
return NimbusJwtEncoder.withSecretKey(key).build();
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// 動作確認用のメモリ内ユーザー。実運用ではDBから読む UserDetailsService に置き換える
@Bean
UserDetailsService userDetailsService(PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername("demo").password(encoder.encode("password")).roles("USER").build());
}
@Bean
AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(uds);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
}
.oauth2ResourceServer(oauth2 -> oauth2.jwt(...)) の1行が、Authorization ヘッダーからトークンを取り出し、JwtDecoder で署名と exp を検証する処理を担います。CSRF対策を無効にしているのは、トークンをCookieではなくヘッダーで送る方式で、ブラウザが自動で認証情報を付けないためです。WebSecurityConfigurerAdapter を継承する古い書き方はSpring Security 6.0で削除されているので、そのコードは現行版ではコンパイルできません。
ログインAPIでのJWTの発行
ユーザー名とパスワードを AuthenticationManager で確かめ、成功したら有効期限15分のJWTを返します。
package com.example.backend;
import java.time.Instant;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AuthController {
public record LoginRequest(String username, String password) {}
public record TokenResponse(String accessToken, long expiresIn) {}
public record ProfileResponse(String username) {}
private final AuthenticationManager authenticationManager;
private final JwtEncoder jwtEncoder;
public AuthController(AuthenticationManager authenticationManager, JwtEncoder jwtEncoder) {
this.authenticationManager = authenticationManager;
this.jwtEncoder = jwtEncoder;
}
@PostMapping("/api/auth/login")
public TokenResponse login(@RequestBody LoginRequest req) {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(req.username(), req.password()));
Instant now = Instant.now();
long ttl = 900;
JwtClaimsSet claims = JwtClaimsSet.builder()
.subject(auth.getName())
.issuedAt(now)
.expiresAt(now.plusSeconds(ttl))
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
String token = jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
return new TokenResponse(token, ttl);
}
@GetMapping("/api/me")
public ProfileResponse me(@AuthenticationPrincipal Jwt jwt) {
return new ProfileResponse(jwt.getSubject());
}
}
検証の設定項目はSpring SecurityのOAuth2 Resource Server JWTのドキュメントにまとまっています。外部の認証基盤(Keycloak、Auth0、Cognitoなど)が発行したトークンを検証する場合は、JwtEncoderとログインAPIを作らず、spring.security.oauth2.resourceserver.jwt.issuer-uri に発行元を設定する構成に変わります。フォームログインやDB認証を含めた認証の実装全般はSpring Bootの認証をSpring Securityで実装する方法【Spring Security 6/7対応・2026年版】で扱っています。
curlで確かめた認証の動き
上の2クラスを入れたアプリに、curlでリクエストを送った結果です。
| リクエスト | 応答 |
|---|---|
| GET /api/me(トークンなし) | 401 |
| POST /api/auth/login(誤パスワード) | 401 |
| POST /api/auth/login(正しい値) | 200・accessToken |
| GET /api/me(発行したトークン) | 200・{“username”:”demo”} |
| GET /api/me(署名を改ざん) | 401・invalid_token |
改ざんしたトークンでは、WWW-Authenticate ヘッダーに error="invalid_token" と「Invalid signature」を含む説明が返りました。発行されたJWTのヘッダーは alg がHS256で、kid は NimbusJwtEncoder が自動で付けていました。ログイン後に401が返ったら、このヘッダーを見るとトークン切れか署名不一致かを切り分けられます。
ReactからのJWT送信と保存場所
ログインAPIで受け取ったトークンを、以降のリクエストの Authorization ヘッダーに付けます。
async function login(username, password) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) throw new Error('ログインに失敗しました')
return (await res.json()).accessToken
}
async function fetchProfile(token) {
const res = await fetch('/api/me', {
headers: { Authorization: `Bearer ${token}` },
})
if (res.status === 401) throw new Error('再ログインが必要です')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
保存場所は localStorage を避けます。OWASPのHTML5 Security Cheat Sheetは、XSSが1つあれば localStorage の中身はすべて読み出せるとして、セッション識別子のような機密情報を保存しないよう勧めています。ヘッダー方式のままなら、トークンはReactのstateなどメモリに置き、有効期限を短くするのが現実的です。この場合、ページを再読み込みするとトークンが消えるので、再ログインが必要になります。
JavaScriptから読めない HttpOnly Cookieにトークンを入れる方式もあります。ただしこの記事のコードのままでは切り替わりません。ログインAPIでJSONの代わりに Set-Cookie を返す処理、Resource Serverが既定で読む Authorization ヘッダーではなくCookieからトークンを取り出す BearerTokenResolver、そしてCSRF対策の3つが要ります。ブラウザがCookieを自動で送るので、上の設定で無効にしたCSRF対策が必要に戻るためです。CSRFについては、Spring Security 7にSPA向けの設定をまとめた csrf(csrf -> csrf.spa()) があり、React側は XSRF-TOKEN Cookieの値を、POSTなどのリクエストの X-XSRF-TOKEN ヘッダーに付けて送ります(CSRFのドキュメント)。
手順5:Reactのビルド成果物のjarへの同梱
本番では、Reactのビルド成果物をSpring Bootのjarに入れて1つのサーバーから配信できます。同じオリジンから配信されるので、手順3のCORS設定は本番では使われなくなります。
Viteのビルド出力先とSpring Bootのstatic
Spring Bootのリファレンスのとおり、Spring Bootはクラスパス上の /static(ほかに /public、/resources、/META-INF/resources)にあるファイルを配信し、index.html をトップページとして扱います。Viteの build.outDir は既定で dist なので、出力先を書き換えます。
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
build: {
outDir: '../backend/src/main/resources/static',
emptyOutDir: true,
},
})
emptyOutDir を外してビルドすると、「(!) outDir … is not inside project root and will not be emptied. Use –emptyOutDir to override.」と表示され、出力先の古いファイルが消されずに残りました。Viteはプロジェクトの外にある出力先を、事故防止のために既定では空にしません(build.emptyOutDir)。ビルドのたびにハッシュ付きのJSが増えていくので、static をReact専用にしたうえで true を明示します。
Mavenビルドへのフロントエンドビルドの組み込み
npm run build と ./mvnw package を毎回手で順に実行する代わりに、frontend-maven-plugin(最新は2.0.2・Maven 3.6とJava 17以上が必要)でMavenから呼べます。backend/pom.xml の <plugins> に追加します。
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>2.0.2</version>
<configuration>
<workingDirectory>../frontend</workingDirectory>
<nodeVersion>v24.12.0</nodeVersion>
</configuration>
<executions>
<execution>
<id>install-node-and-npm</id>
<goals><goal>install-node-and-npm</goal></goals>
</execution>
<execution>
<id>npm-ci</id>
<goals><goal>npm</goal></goals>
<configuration><arguments>ci</arguments></configuration>
</execution>
<execution>
<id>npm-build</id>
<goals><goal>npm</goal></goals>
<configuration><arguments>run build</arguments></configuration>
</execution>
</executions>
</plugin>
この状態で ./mvnw package を実行すると、Node.js v24.12.0のダウンロード、npm ci、npm run build が generate-resources フェーズで順に走り、できたjarの BOOT-INF/classes/static/ に index.html と assets が入りました。Node.jsは frontend/node に置かれるので、このディレクトリは .gitignore に加えます。npm ci は package-lock.json が必要なので、ロックファイルもコミットしてください。
React Routerの直リンクとindex.htmlへのフォールバック
同梱しただけでは、React Routerで作った /users/1 のような画面を、ブラウザのアドレス欄から直接開いたりリロードしたりすると404になります(手元でも404でした)。そのURLに対応するファイルが static に無いためです。/api 配下でなく、URLの最後の区切りにドットを含まないGETリクエストに index.html を返すルートを足します。ファイルの実在は確かめないので、画面のURLにドットを入れない設計が前提です。
package com.example.backend;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.servlet.function.RequestPredicate;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerResponse;
import static org.springframework.web.servlet.function.RequestPredicates.GET;
import static org.springframework.web.servlet.function.RequestPredicates.path;
@Configuration
public class SpaRouterConfig {
@Bean
RouterFunction<ServerResponse> spaFallback() {
RequestPredicate spaRoute = GET("/**")
.and(path("/api/**").negate())
.and(path("/error").negate())
.and(request -> {
String path = request.path();
return !path.substring(path.lastIndexOf('/') + 1).contains(".");
});
return RouterFunctions.route()
.resource(spaRoute, new ClassPathResource("static/index.html"))
.build();
}
}
条件は「GETであること」「/api 配下でないこと」「/error でないこと」「最後の区切りに拡張子が無いこと」の4つです。このうち /error の除外を最初は入れずに動かしたところ、存在しない /nofile.txt や /users/1.json まで、ステータス200で index.html が返りました。存在しないファイルへのリクエストは、Spring Bootの中で一度404になってからエラーページ用の /error へ転送されます。その /error がこのルートの条件に当てはまり、index.html が200で返ってしまうためです。
| リクエスト | /error除外なし | /error除外あり |
|---|---|---|
| GET /users/1 | 200 HTML | 200 HTML |
| GET /assets/index-*.js | 200 JS | 200 JS |
| GET /nofile.txt | 200 HTML | 404 |
| GET /api/unknown | 401 | 401 |
/api/unknown が401になるのは、/api/** をすべて認証必須にしているためです。
同梱と別ホスティングの選び方
社内システムや、フロントエンドとバックエンドを同じチームが同じ周期でリリースする構成なら、同梱を選びます。jarが1つで済み、CORSの設定も要りません。
次のどれかに当てはまるなら、同梱はやめて別ホスティングにします。フロントエンドだけを1日に何度もデプロイしたい場合、同梱ではAPIサーバーごと再起動になります。静的ファイルをCDNから配信したい場合や、Reactの担当チームがAPIと別の周期でリリースする場合も、Reactを静的ホスティングに置いてAPIだけをSpring Bootで動かし、手順3のCORS設定で許可するほうが運用が単純です。
既存の業務システムにReactの画面とREST APIを足す設計や、社外のサービスとのAPI連携まで含めて相談したい場合は、株式会社一創のAPI開発・システム連携でも対応しています。
よくある質問
Spring BootとReactは同じリポジトリにまとめるべきですか?
同じチームが同じ周期でリリースするなら、この記事のように backend と frontend を1つのリポジトリに並べると、APIの変更と画面の変更を1つのプルリクエストで扱えます。チームやリリース周期が分かれるなら、リポジトリも分けたほうが権限とCIを管理しやすくなります。
CORSエラーはReact側とSpring Boot側のどちらで直しますか?
許可を出すのはSpring Boot側です。ブラウザはサーバーの応答ヘッダーを見て判断するため、React側のコードでは解消できません。開発中だけはViteのプロキシで同一オリジンにして、CORSを発生させない方法が使えます。Spring MVC側でCORSを設定し、Spring Securityの SecurityFilterChain を自作しているなら http.cors() も必要です。
WebSecurityConfigurerAdapterが使えないのはなぜですか?
Spring Security 5.7で非推奨になり、6.0で削除されたためです。Spring Boot 3.0以降はSecurity 6以上を使うので、継承するコードはコンパイルできません。SecurityFilterChain をBeanとして定義する書き方に置き換えます。Spring Securityの全体像はSpring Securityとは?認証・認可の仕組みと基本設定をわかりやすく解説【2026年版】で整理しています。
JWTはlocalStorageに保存してよいですか?
推奨しません。XSSが1つあればJavaScriptから読み出されます。ヘッダー方式ならメモリに置いて有効期限を短くし、Cookie方式なら HttpOnly を付けたうえでCSRF対策を有効にします。
react-springとSpring Bootは関係がありますか?
関係ありません。react-springはReactのアニメーションライブラリで、名前が似ているだけです。JavaのSpring BootとReactを連携する方法を知りたい場合は、この記事の手順1から読み進めてください。