---
title: "Spring BootとReactの連携方法｜REST API・CORS・JWT認証から1つのjarへの同梱まで【Spring Boot 4.1】"
url: "https://www.issoh.co.jp/tech/details/1542/"
published: 2023-10-05
updated: 2026-10-11
categories: ["Spring"]
publisher: "株式会社一創"
---

# Spring BootとReactの連携方法｜REST API・CORS・JWT認証から1つのjarへの同梱まで【Spring Boot 4.1】

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フロントエンドの役割分担](/tech/details/2027/)も参考になります。

## 検証環境とディレクトリ構成

この記事のコードは、次の環境で実際にビルドして動かしています（2026年10月11日）。Spring Bootの版とサポート期限は[Spring Bootのプロジェクトページ](https://spring.io/projects/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後の選び方](/tech/details/5172/)に、4系で変わった点は[Spring Boot 4とは？最新バージョン4.1の変更点・新機能とSpring Boot 3との違いを解説【2026年最新】](/tech/details/10124/)にまとめています。

ディレクトリは、1つのリポジトリにバックエンドとフロントエンドを並べる構成にします。

```
my-app/
├── backend/    # Spring Boot（Maven）
└── frontend/   # React（Vite）
```

## 手順1：Spring BootでのREST API実装

### Spring Initializrで選ぶ依存関係

[Spring Initializr](https://start.spring.io/)で、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での変更点](/tech/details/4048/)で一覧にしています。

## 手順2：ViteでのReact環境構築とAPI呼び出し

### create-viteでのReactプロジェクト作成

Reactの雛形はViteで作ります。以前の定番だったCreate React Appは、React公式が[2025年2月14日に新規利用の非推奨](https://react.dev/blog/2025/02/14/sunsetting-create-react-app)を告知しています。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からの移行](/tech/details/3587/)で詳しく扱っています。

### 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が止めるもの・止めないもの](/tech/details/7544/)、プリフライトの仕組みは[CORSとは？仕組み・プリフライト・サーバー設定例・エラー解決を実装目線で解説](/tech/details/13763/)で解説しています。

### 開発時：Viteのプロキシによる同一オリジン化

開発中は、Viteの開発サーバーに `/api` 宛てのリクエストを中継させます。ブラウザから見ると5173番にしかアクセスしていないため、CORSそのものが起きません。`vite.config.js` に [server.proxy](https://vite.dev/config/server-options) を追加します。

```
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のドキュメント](https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html)にあるとおり、`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対応】](/tech/details/7001/)で解説しています。

### SecurityFilterChainでのJWT検証の設定

鍵は1つの秘密鍵で署名と検証を行うHS256を使います。[RFC 7518](https://www.rfc-editor.org/rfc/rfc7518#section-3.2)は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のドキュメント](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)にまとまっています。外部の認証基盤（Keycloak、Auth0、Cognitoなど）が発行したトークンを検証する場合は、`JwtEncoder`とログインAPIを作らず、`spring.security.oauth2.resourceserver.jwt.issuer-uri` に発行元を設定する構成に変わります。フォームログインやDB認証を含めた認証の実装全般は[Spring Bootの認証をSpring Securityで実装する方法【Spring Security 6/7対応・2026年版】](/tech/details/2467/)で扱っています。

### 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](https://cheatsheetseries.owasp.org/cheatsheets/HTML5%5FSecurity%5FCheat%5FSheet.html)は、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のドキュメント](https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html)）。

## 手順5：Reactのビルド成果物のjarへの同梱

本番では、Reactのビルド成果物をSpring Bootのjarに入れて1つのサーバーから配信できます。同じオリジンから配信されるので、手順3のCORS設定は本番では使われなくなります。

### Viteのビルド出力先とSpring Bootのstatic

[Spring Bootのリファレンス](https://docs.spring.io/spring-boot/reference/web/servlet.html)のとおり、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](https://vite.dev/config/build-options)）。ビルドのたびにハッシュ付きの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開発・システム連携](/service/system/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年版】](/tech/details/2465/)で整理しています。

### JWTはlocalStorageに保存してよいですか？

推奨しません。XSSが1つあればJavaScriptから読み出されます。ヘッダー方式ならメモリに置いて有効期限を短くし、Cookie方式なら `HttpOnly` を付けたうえでCSRF対策を有効にします。

### react-springとSpring Bootは関係がありますか？

関係ありません。react-springはReactのアニメーションライブラリで、名前が似ているだけです。JavaのSpring BootとReactを連携する方法を知りたい場合は、この記事の手順1から読み進めてください。

## 関連記事

- [Spring Bootの認証をSpring Securityで実装する方法【Spring Security 6/7対応・2026年版】](/tech/details/2467/)
- [CORSとは？仕組み・プリフライト・サーバー設定例・エラー解決を実装目線で解説](/tech/details/13763/)
- [Viteとは？Reactの環境構築手順とVite 8の変更点・create-react-appからの移行](/tech/details/3587/)
- [SecurityFilterChainとは？Spring Securityのフィルタ実行順序と設定例【Spring Security 7対応】](/tech/details/7001/)
- [Spring Boot 4とは？最新バージョン4.1の変更点・新機能とSpring Boot 3との違いを解説【2026年最新】](/tech/details/10124/)

---

出典: [Spring BootとReactの連携方法｜REST API・CORS・JWT認証から1つのjarへの同梱まで【Spring Boot 4.1】](<https://www.issoh.co.jp/tech/details/1542/>)（株式会社一創）
