はじめに
概要
Java × React フルスタック案件への参画を目指し、
Spring Boot(Java)+ React(TypeScript)+ JWT認証 を使った ToDo アプリを個人開発しました。
主な実装ポイントをピックアップして、解説していきます。
なぜつくったか
現在、SES企業でJavaエンジニアとして業務に携わっていますが、担当システムは JSPやThymeleafのようなサーバーサイドテンプレートで画面を生成する従来型の構成でした。
この方式はシンプルで安定している反面、画面遷移ごとにリロードが発生し、ユーザー体験の面ではモダンなWebサービスに比べて見劣りする部分があります。
そこで、フロントエンド側でUIを動的に描画し、API経由でデータを取得して、スムーズかつ直感的な操作感を実現できるフレームワーク React に注目しました。
これまで業務で培った Java の経験を活かしつつ、モダンなフロントエンドにも挑戦したいと考えました。特に React は Spring Boot で構築した REST API と組み合わせやすく、モダンなフルスタック開発を学ぶ題材として最適だと思い、この構成を選びました。
加えて、CI/CDやDocker、モダンなデプロイ手法も学習対象に含め、最終的にToDoアプリを本番環境へデプロイするまで取り組みました。
技術スタック
技術スタック
Backend
- Spring Boot 3.5.4
- MyBatis
- Java 21
Auth / Security
- JWT(発行・検証、Bearer トークン運用)
Database
- PostgreSQL
- Flyway(Render 起動時マイグレーション)
Frontend
- React + TypeScript
- TanStack Query
- React Hook Form(RHF)
- Tailwind CSS
CI/CD / Tooling
- Node.js, Dockerfile
- GitHub Actions(Build/Test/Deploy Hook)
Hosting
- Render(バックエンド / DB)
- Vercel(フロントエンド)
システム構成図
認証
JWT(JSON Web Token)
React分離のSPA(SinglePageApplicatiuon)+REST構成なので、Spring Securityはstateless JWTを前提に設計しました。
Stateless JWT とは、サーバー側にセッション状態を保持せず、クライアントが持つ JWT(JSON形式の署名付きトークン) をリクエストごとに検証する方式です。これによりサーバーは「無状態」で動作でき、スケールしやすくSPAやモバイルアプリとの相性も良い反面、トークンの失効管理が課題となります。
なぜJWT方式を採用したか
ReactによるSPAとSpring Boot APIの分離構成では、サーバーにセッションを保持するよりも「statelessに認証を扱えるJWT」の方が相性が良いためです。RenderやVercelのようなクラウド環境でもスケールさせやすく、近年のモダンWebサービスでも広く利用されている方式です。従来の業務系Javaシステムではセッション管理が一般的ですが、SPAや外部APIを想定した構成ではJWTがよく使われるため、実務に近い形で学習・実装しました。
JWT設定箇所
@Bean
public OncePerRequestFilter jwtAuthFilter() {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
// ---- 1) 素通し対象(公開パス/Swagger/静的ファイル/プリフライト)----
final String path = req.getServletPath();
if ("OPTIONS".equalsIgnoreCase(req.getMethod())
|| path.startsWith("/auth/")
|| path.startsWith("/v3/api-docs")
|| path.startsWith("/swagger-ui")) {
chain.doFilter(req, res);
return;
}
→認証不要箇所の設定です。
/auth/はログイン、会員登録のAPIです。/v3/api-docs と /swagger-ui は Spring Bootが自動で公開してくれるAPIドキュメントです。
開発中はこの画面を使ってAPIをテストしたり、フロントエンドの型定義を自動生成したりします。
// ---- 2) Authorizationが無ければ何もせず次へ(匿名のままSecurityへ)----
final String h = req.getHeader("Authorization");
if (h == null || !h.startsWith("Bearer ")) {
chain.doFilter(req, res);
return;
}
Authorizationヘッダが無いときは後続のHTTPリクエスト認証で弾きます。
// ---- 3) Bearerがあるときだけトークン検証し、OKなら認証をセット ----
try {
final String token = h.substring(7);
final String username = jwt.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails ud = uds.loadUserByUsername(username);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(ud, null, ud.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(req, res);
毎回認証チェックが入るため。認証済の場合、getAuthentication()==nullでその場でレスポンスを返します。
catch (ExpiredJwtException e) {
res.setStatus(401);
res.setHeader("WWW-Authenticate", "Bearer error=\"invalid_token\", error_description=\"expired\"");
}
catch (Exception e) {
res.setStatus(401);
res.setHeader("WWW-Authenticate", "Bearer error=\"invalid_token\", error_description=\"invalid or malformed\"");
}
JWTが期限切れや改ざんなどで無効な場合、HTTPステータスを 401 Unauthorized に統一し、レスポンスヘッダに WWW-Authenticate を付与しました。これによりクライアント側は「再ログインが必要」とシンプルに判断でき、フロントの実装を共通化できます。
CORS(Cross-Origin Resource Sharing )
CORSとは「ブラウザが、別のオリジン(プロトコル + ドメイン + ポート番号)にあるサーバーへアクセスできるかどうかを制御する仕組み」です。
前提として、主要ブラウザでは、Same-Origin Policy:「違うオリジンへのアクセスは基本ブロックする」という仕様があります。
これは、悪意のあるサイトが勝手に他のサイトへアクセスしてユーザー情報を盗むのを防ぐためです。
従来の サーバーサイドテンプレートエンジン(JSPやThymeleafなど) では、HTMLをサーバー(Java)から直接返すため同一オリジン内で完結し、CORS設定は不要でした。
一方で、Reactのようにフロントとバックエンドを分離した構成では「異なるオリジン間通信」となるため、サーバー側でCORSの許可が必要になります。
CORS設定箇所
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(req -> {
CorsConfiguration c = new CorsConfiguration();
c.setAllowedOriginPatterns(List.of(
"https://*.vercel.app", // Vercel preview & production
"http://localhost:5173" // 開発用(Vite等)
));
c.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
c.setAllowedHeaders(List.of("*"));
c.setAllowCredentials(true);
return c;
}))
-
csrf.disable()
→ フロント分離のREST APIではCSRFトークンを使わないため無効化。 -
sessionCreationPolicy(STATELESS)
→ サーバにセッションを持たず、毎リクエストでJWT検証する方針。 -
c.setAllowedMethods
→ 許可メソッド/ヘッダを明示 (GET/POST/PUT/DELETE/OPTIONS, "*" など)。
.authorizeHttpRequests(auth -> auth
// プリフライト許可
.requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll()
)
OPTIONSリクエスト(CORSプリフライト)は誰でも通していいよ、という設定
「プリフライトリクエスト」とは?
ブラウザが「ちょっと確認してから本リクエストを送る」仕組みです。
例えば POST や PUT など「単純じゃないリクエスト」を送る前に、
ブラウザが勝手に OPTIONS メソッドでサーバーにこう聞きます:
「このオリジン(例: http://localhost:5173)
からこのAPIにアクセスしてもいいですか?」
これを CORSプリフライトリクエスト と呼びます。
🔹なぜ許可が必要なのか?
Spring Security はデフォルトだと すべてのリクエストに認証が必要と考える。
するとこの OPTIONS リクエストも「未認証だから401/403で拒否」してしまう。
そうなると、本番のリクエスト(POSTなど)が送られる前にブロックされる → 「CORSエラー」と表示されるため。
.requestMatchers("/auth/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.anyRequest().authenticated()
)
- /auth/**
→ ログインや会員登録などは未ログインでもアクセスできる必要がある - /v3/api-docs/** /swagger-ui/**
→ APIドキュメント(Swagger UI)は開発用に公開
上記以外のAPIはすべて「JWTを持っているユーザーだけ」通れる
実装ポイント
バックエンド
認証:AuthenticationManagerBuilder でID/パスワード検証
Authentication a = amb.getObject().authenticate(
new UsernamePasswordAuthenticationToken(req.username(), req.password()));
SecurityContextHolder.getContext().setAuthentication(a);
return new LoginResponse(jwt.generateToken(req.username()));
Spring Securityの標準フローでユーザー認証 → 成功したら JWT を発行。
パスワードの安全保存:BCryptPasswordEncoder
u.setPassword(passwordEncoder.encode(req.password())); // BCrypt
Todo関連
private Integer currentUserId() {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
User user = userMapper.findByUsername(username);
if (user == null|| username.equals("anonymousUser")) {
throw new UsernameNotFoundException("ユーザーが見つかりません: " + username);
}
return user.getId();
}
SecurityContextHolder からユーザー名を取り出し、DBのUserテーブルと突き合わせて userIdを解決。
@GetMapping("/{id}")
public ResponseEntity<Todo> get(@PathVariable Integer id) {
Todo t = mapper.findByIdAndUserId(id, currentUserId());
return (t==null) ? ResponseEntity.notFound().build() : ResponseEntity.ok(t);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable Integer id) {
int rows = mapper.delete(id, currentUserId());
return (rows==0) ? ResponseEntity.notFound().build() : ResponseEntity.noContent().build();
}
適切なHTTPステータスを返すことで、フロントエンド側の処理をシンプルにしました。
get() → 存在しない場合は 404 Not Found
update() / delete() → 成功時は 204 No Content、対象が無ければ 404
フロントエンド
1) 認可ガード(保護ルート)— src/RequireAuth.tsx
import { Navigate, Outlet } from "react-router-dom";
export default function RequireAuth() {
const token = localStorage.getItem("token");
if (!token) return <Navigate to="/login" replace />;
return <Outlet />; // ← 許可されたときだけ子ルートをマウント
}
ルーティング設定で /todos/* に上記RequireAuthを適用することで ログイントークンがない場合、
ログイン画面を表示するようにしました。
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/userRegist", element: <UserRegist /> },
{ path: "/registerSent", element: <RegisterSent /> },
{
element: <RequireAuth />,//ログイン必要
children: [
{
element: <Layout />,
children: [
{ path: "/", element: <TodoListPage /> },
{ path: "/todos/:id", element: <TodoDetailPage /> },
],
},
]
}
]);
React Router v6(URLに応じて表示するコンポーネントを切り替える 仕組み)を使用して、「公開ページ」と「認証必須ページ」を分離しました、
2)バックエンドAPIへのリクエスト方法
バックエンドAPIへのリクエストは2通りあります。
1つは、CORS制御を利用して、バックエンドURLを直叩きする方法です。
もう1つは、フロントは常に /api/... に向け、バックとフロントを同一オリジンに見立てる方法です。
本アプリは後者を採用しています。
A. 直叩き構成(まず動かす最小例/CORSが要る)
export const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // ← フルURL
withCredentials: false, // Cookie セッションでなければ false のままでOK
});
上記で、VITE_API_BASE_URLに環境変数でバックエンドの絶対URLを定義し、フロントはそのURLを直接叩くことで、リクエスト可能になります。
B. 同一オリジン化(Vite/Vercel中継で CORS 不要)
フロントは常に /api/... に向け、開発は Vite の proxy、本番は Vercel の rewrites が裏で中継します。
server: {
proxy: {
// フロントからの /api → Spring Boot(8081) に転送
'/api': {
target: 'http://localhost:8081',
changeOrigin: true,
// Spring Boot 側で /api プレフィックスを付けてない場合は↓有効化
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
1. 転送の仕組み
フロント(Vite + React)は http://localhost:5173
バックエンド(Spring Boot)は http://localhost:8081
フロントからの /api/... リクエストは Vite の proxy が 8081 に転送してくれる
ブラウザから見れば「5173にアクセスしている」扱いになるため、CORSを気にせず開発できる
2. なぜ /api を付けるのか?
衝突を避ける
例えば /todos というパスが「フロントのルーター」なのか「バックエンドAPI」なのか分からなくなる。
/api/todos とすれば両者を区別できる。
見分けやすい
URLを見ただけで「これはAPIリクエスト」と一目で分かる。
開発時の振り分けに使える
proxy は「/api/... だけ 8081 に飛ばす」というルールを書ける。
CORS設定をしなくても、フロントとバックエンドをつなげられる。
3. /api は“ダミーの入り口"
/api はバックエンドの仕様ではなく、フロント側の都合で付けている目印。
例えばフロントから /api/todos を呼ぶと…
Vite proxy:「これはAPIだから 8081 に転送しよう」
rewrite が有効なら /api を外して /todos に変換 → バックエンドに届く。
つまり /api は「開発時にCORSを避けるためのダミーの入り口」であり、
本番でもAPIを見分けやすくするラベルの役割を果たしています。
4.Axios 設定の意味
export const http = axios.create({
baseURL: "/"
withCredentials: false, // Cookie セッションでなければ false のままでOK
});
Vite が /api を 8081 に中継してくれるため、"/"(相対URL)を設定。
5.needsAuth の動き(なぜ「絶対URL」基準か)
const base =
http.defaults.baseURL && /^https?:\/\//i.test(http.defaults.baseURL)
? http.defaults.baseURL
: window.location.origin; // dev時(baseURL="/")は 5173 を使う
const u = new URL(url, base);
const p = u.pathname;
baseURL が 相対(開発時)だと new URL() が作れないため、
window.location.origin(例:http://localhost:5173
)を土台にして絶対URL化
3)TanStack Query
const { data, isLoading, error } = useQuery({
queryKey: ["todos"],
queryFn: async () => (await http.get<Todo[]>("/api/todos")).data,
enabled: !!token,
});
const createMutation = useMutation({
mutationFn: async (payload: { title: string; description?: string }) =>
(await http.post("/api/todos", { ...payload, done: false })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["todos"] })
});
TanStack Queryは、データフェッチやサーバー状態管理を効率的に行う
ためのライブラリです。
-
queryKey
→ 「キャッシュの名前」。同じキーを使えばデータが再利用される。 -
queryFn
→ 実際にAPIを叩く処理。エラー時は自動で error に入り、リトライもしてくれる。 -
enabled: !!token
→ トークンが無いときはリクエストを送らない。未ログイン時に無駄なAPIアクセスを防げる。
👉 メリット:ローディング・エラー処理・キャッシュ管理を全部 useQuery がやってくれる。
const createMutation = useMutation({
mutationFn: async (payload: { title: string; description?: string }) =>
(await http.post("/api/todos", { ...payload, done: false })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ["todos"] })
});
-
mutationFn
→ 「登録・更新・削除」のような副作用を伴う処理をここに書く。 -
onSuccess
→ 成功したら「["todos"] のキャッシュを無効化」して、一覧を再取得させる。 -
invalidateQueries
→ 「このキャッシュは古いから捨ててもう一度取りに行って」と指示する関数。
👉 メリット:新規作成後にリストが自動で最新化される。手でリロード不要。
4)React HookForm
import { useForm } from "react-hook-form";
type Form = { username: string; password: string };
export default function LoginPage() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<Form>();
const onSubmit = async (d: Form) => {
const res = await http.post("/api/auth/login", d);
localStorage.setItem("token", res.data.token);
localStorage.setItem("username", d.username);
nav("/");
};
<form onSubmit={handleSubmit(onSubmit)}>
<button className="btn" disabled={isSubmitting}>ログイン</button >
</form>
React Hook Formは、React で フォームを簡単かつ高性能に扱えるライブラリ
です。
通常の useState でフォームを管理すると、入力ごとにステートやonChangeを用意し、バリデーションも手で書く必要があります。
React Hook Formを使えば、register でフォーム要素を紐づけるだけで入力値・エラー・送信状態を一元管理でき、コード量が大幅に減り、保守性も向上します。
register でフォーム要素を簡単に管理でき、送信時は handleSubmit が自動的にバリデーションを行います。ログイン成功時にはサーバーから受け取ったJWTを localStorage に保存し、認証が必要なAPI呼び出しに利用できるようにしました。
デプロイ
以下、Render*Vercelでのデプロイ、GithubaActionsでのCI/CDについてまとめました。
https://qiita.com/MiyaHamu86/items/4fd4f63d3d58c1922eca
学んだこと・今後の課題
SPAを学ぶ前は、フォーム送信といえば必ず画面遷移を伴うものだと思っていました。しかし React での開発では、画面遷移を経ずに即座にUIが更新されることを体験し、Webアプリの新しい当たり前を知ることができました。今後はこの体験を活かしつつ、実務レベルの自動テスト機能(DB接続,認証関連)の実装など、より実用的な課題にも挑戦したいです。
最後に
今回は Spring Boot × React × JWT認証 を用いたフルスタックTodoアプリを題材に、バックエンドからフロント、CI/CDやデプロイまで一通り実装しました。
学習のアウトプットを兼ねていますが、同じように「Javaエンジニアとしてモダンなフロント技術やクラウドデプロイも触れてみたい」という方の参考になれば嬉しいです 🙌
以下、実際に触れるデモ環境とソースコードです。
※バックエンドAPI初回接続時、Renderサーバがスリープモードから起動するため2分程度かかります。15分動作がないと再度スリープモードへ移ります。
💻 GitHub:todo-java-react リポジトリ
ご意見・ご指摘などありましたらぜひコメントでいただけると嬉しいです。