ReactとDjangoをつなぐ基本形は、DjangoがDjango REST Framework(DRF)でJSONを返し、ReactがそのJSONを画面に描く役割分担です。詰まる場所は決まっていて、APIの作成、CORS、axiosでの呼び出し、ログインの4つです。本記事では、2026年10月29日時点の現行版(Django 6.1.2・DRF 3.18.3・Vite 8.3.5・React 19.3.1)で実際に動かしたコードを使い、この4つを順に組み立てます。その前に「そもそもDjangoのフロントエンドにReactが要るのか」を判断できるよう、構成の選択肢から始めます。
まとめ:ReactとDjangoの連携で先に決める構成・版・認証
- 構成:画面数が少ない社内ツールはDjangoテンプレートかhtmxで足ります。Reactを入れるのは、画面の状態や操作が複雑なSPAを作る場合です。
- 版:新規はDjango 6.1系を使います。SimpleJWT 5.5.1はPyPIの対応表記がDjango 5.2までですが、6.1.2でトークン発行・更新・401応答まで動きました。
- API:DRFの
ModelViewSetとDefaultRouterでCRUDを公開し、DEFAULT_PERMISSION_CLASSESを必ず指定します。指定しないと誰でも書き込めます。 - CORS:開発中はViteの
server.proxyで同一オリジンに見せるのが手早く、本番も同一ドメインならCORS設定自体が要りません。 - 認証:同一ドメイン配信ならセッション+CSRF(自作より
allauth.headless)、別ドメインやモバイル共用ならSimpleJWTのJWTを選びます。
Djangoのフロントエンド構成5択と、Reactを組み合わせる条件
Djangoは画面まで作れるフレームワークで、Reactは必須ではありません。テンプレートでHTMLを返すのがDjangoの標準の作り方であり、その仕組みはDjangoとは?特徴とMVTの仕組み・始め方をDjango 6.1で解説で扱っています。React以外も含め、選択肢は次の5つです。
| 構成 | 画面を作る側 | CORS | 向く認証 | 向く案件 |
|---|---|---|---|---|
| Djangoテンプレート | Django | 不要 | セッション | 社内CRUD・管理画面中心 |
| テンプレート+htmx | Django(部分更新) | 不要 | セッション | 一部だけ画面遷移なしにしたい |
| テンプレートにReactを埋め込む | Djangoと一部React | 不要 | セッション | 既存Djangoの特定画面だけ作り込む |
| React SPA+DRF | React | 構成次第 | セッションかJWT | SPA・モバイルとAPI共用 |
| Next.js前段+DRF | Next.js(SSR) | 構成次第 | JWTなど | 検索流入が要る公開ページ |
Djangoだけで画面まで作れる範囲:テンプレートとhtmx
一覧・登録・編集・検索が中心の業務画面なら、テンプレートとDjango標準の管理画面で完結します。ボタンを押したら一覧だけ差し替えたい、という程度の動きはhtmxのhx-getやhx-postで書けるので、JavaScriptのビルド環境を持たずに済みます。htmxの属性の使い方はhtmxの使い方:hx-属性の実装手順からFastAPI連携まで解説にまとめています。この範囲で済むなら、React・ビルド・CORS・トークン管理を抱えるのは保守の手間が増えるだけです。
既存テンプレートの一部画面にReactを埋め込むdjango-vite構成
既存のDjangoアプリのダッシュボードだけをReactにしたい場合は、Djangoが返すHTMLにViteのビルド成果物を読み込みます。同一オリジンなのでCORSは要らず、ログインも既存のセッションのままです。読み込みタグを生成するdjango-viteの最新は3.2.0(2026年9月14日公開)で、PyPIの対応表記はDjango 4.2までですが、6.1.2で次の設定のタグ出力を確認しました。
# config/settings.py(pip install django-vite)
INSTALLED_APPS += ["django_vite"]
STATICFILES_DIRS = [BASE_DIR / "assets"]
DJANGO_VITE = {
"default": {
"dev_mode": DEBUG,
"manifest_path": BASE_DIR / "assets" / ".vite" / "manifest.json",
}
}
// frontend/vite.config.js
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
base: '/static/',
build: {
manifest: true,
outDir: '../backend/assets',
rolldownOptions: { input: 'src/main.jsx' },
},
})
{# templates/dashboard.html #}
{% load django_vite %}
{% vite_hmr_client %}
{% vite_react_refresh %}
{% vite_asset 'src/main.jsx' %}
<div id="root"></div>
dev_modeがTrueならhttp://localhost:5173のVite開発サーバーとHMR用スクリプトを読むタグ、Falseならmanifest.jsonからハッシュ付きの/static/assets/main-*.jsを読むタグが出ます。Vite 8ではbuild.rollupOptionsが非推奨になりrolldownOptionsへ名前が変わっているため、古い記事の設定を写すときは注意してください。Djangoの値をReactへ渡す方法はDjangoテンプレートの書き方:継承とコンテキスト・JSへの値渡しを参照してください。
React SPA+DRFに分離すべき条件と、採用しない場面
ReactをViteで作った独立アプリにし、DjangoはJSON APIだけを返す構成です。画面遷移が多く状態を持つSPAを作る場合や、フロントとバックを別チームで開発する体制なら、この構成を選びます。iOS・Androidアプリと同じAPIを使う計画があれば、DRFで作ったAPIをそのまま共用できます。Django ORMで定義した既存のモデルをAPIでも再利用できること、Djangoの管理画面を社内向けのデータ登録画面として初日から使えることが、FastAPIなどではなくDjangoを選ぶ根拠になります。どちらも要らない新規APIならReact×FastAPI連携の手順のようにFastAPIで組む方が軽く、Djangoのまま型定義と非同期を重視するならDRFの代わりにDjango Ninjaを使う手もあります。
逆に、検索流入が要る商品ページやメディアには、ViteのSPA単体は使いません。クライアント側で描画するため、初回のHTMLに本文が入らないからです。その場合はDjangoテンプレートで返すか、SSRができるNext.jsを前段に置きます。既存システムとの連携やモバイルとのAPI共用まで含めて設計したい場合は、一創のAPI開発・システム連携でDRFを使ったAPI設計からご相談いただけます。以降は、React SPA+DRF構成を前提に手順を進めます。
検証環境の用意:Django 6.1.2とVite 8の起動手順
作業用ディレクトリの直下にbackendとfrontendを並べて立ち上げます。版によってJWTライブラリの対応表記が変わるため、先に版を決めます。
Djangoの版選びとSimpleJWT 5.5.1の対応表記と実際の動作
Django公式のダウンロードページでは、6.1系(2026年8月5日リリース)の通常サポートが2027年4月、セキュリティ修正が2027年12月までです。5.2 LTSは通常サポートが2025年12月3日に終わり、2028年4月までセキュリティ問題とデータ損失バグの修正だけを受ける延長サポート期間に入っています。次のLTSである6.2は2027年4月の予定です。新規開発は6.1で始め、6.2 LTSが出たら移る計画が現実的です。各系列の期限はDjangoのEOLとサポート期限一覧で整理しています。
迷いやすいのがSimpleJWTです。djangorestframework-simplejwtの最新は5.5.1(2025年7月21日公開)で、その後のリリースはありません。PyPIの対応表記はDjango 4.2〜5.2ですが、依存条件はdjango>=4.2で上限がありません。Django 6.1.2・DRF 3.18.3と組み合わせて、トークン発行、期限切れでの401、リフレッシュによる再発行まで正常に動くことを確認しました。もう1点、SimpleJWTの変更履歴にある通り5.5.1からuser_idクレームが常に文字列になり、実測でも"1"でした。React側でトークンを読んで数値と===で比べているコードは、5.5.1に上げた時点で一致しなくなります。
Djangoプロジェクトと必要ライブラリ4つのインストール
Django 6.1はPython 3.12以上が必要です。仮想環境を作り、Django本体・DRF・CORS・JWTの4つを入れます。
mkdir backend && cd backend
python3.13 -m venv .venv
source .venv/bin/activate # Windowsは .venv\Scripts\activate
pip install "django>=6.1,<6.2" djangorestframework django-cors-headers djangorestframework-simplejwt
django-admin startproject config .
python manage.py startapp tasks
startproject config .の末尾のドットで、入れ子ディレクトリを作らずカレントに展開します。"django>=6.1,<6.2"と書くと6.1系のパッチ版だけを受け取ります。DRF 3.18.3はDjango 5.2以上が前提なので、これより古いDjangoとは組めません。
ViteによるReactプロジェクト作成とNode.jsの要件
React公式ブログは2025年2月14日にcreate-react-appを新規アプリ向けに非推奨とし、Viteなどへの移行を勧めています。ここではViteで作ります。
# backend と同じ階層(作業用ディレクトリの直下)で実行
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm install axios
npm run dev
Vite 8.3.5は^20.19.0 || >=22.12.0のNode.jsを要求するため、Node 18では起動しません。2026年10月のcreate-viteで作ったReactテンプレートには、ESLintではなくOxlintがlintスクリプトとして入っていました。開発サーバーの既定はhttp://localhost:5173で、この値が後のCORS設定の許可オリジンになります。Vite 8の変更点はVite + Reactの環境構築手順|Viteとは・Vite 8.3の変更点にあります。
DRFによるTaskモデルのCRUD API作成とcurlでの応答確認
モデル、シリアライザー、ViewSetの順に書きます。DRFの全体像はDjango REST Framework(DRF)とは?3.18の最小構成と本番前に変える設定で補足しています。
Taskモデルとシリアライザーの公開フィールド指定
題材はタイトルと完了フラグを持つタスク管理です。settings.pyのINSTALLED_APPSに"rest_framework"と"tasks"を足してから、次の2ファイルを書きます。
# tasks/models.py
from django.db import models
class Task(models.Model):
title = models.CharField(max_length=200)
done = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
# tasks/serializers.py
from rest_framework import serializers
from .models import Task
class TaskSerializer(serializers.ModelSerializer):
class Meta:
model = Task
fields = ["id", "title", "done", "created_at"]
read_only_fields = ["id", "created_at"]
fieldsは"__all__"でも動きますが、ユーザーモデルのパスワードハッシュのような列を後から足したときに、そのまま外へ出てしまいます。返す列は明示します。検証や入れ子の書き方はDjango REST FrameworkのSerializer(シリアライザ)とは?で扱っています。
ViewSetとDefaultRouterによるCRUD 5操作の一括公開
ModelViewSetは一覧・取得・作成・更新・削除をまとめて提供し、DefaultRouterがURLを割り当てます。
# tasks/views.py
from rest_framework import viewsets
from .models import Task
from .serializers import TaskSerializer
class TaskViewSet(viewsets.ModelViewSet):
# この例はログインした利用者全員で共有するタスク一覧(利用者ごとの絞り込みはしない)
queryset = Task.objects.order_by("-created_at")
serializer_class = TaskSerializer
# config/urls.py
from django.contrib import admin
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from tasks.views import TaskViewSet
router = DefaultRouter()
router.register("tasks", TaskViewSet)
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include(router.urls)),
]
これで/api/tasks/(一覧・作成)と/api/tasks/{id}/(取得・更新・削除)ができます。
React接続前のAPI単体確認:curlでのGETとPOST
API単体で動くことを先に確かめておくと、後のエラーをDjango側とブラウザ側に切り分けられます。
python manage.py makemigrations tasks
python manage.py migrate
python manage.py createsuperuser # JWTの章でログインに使う
python manage.py runserver
curl http://localhost:8000/api/tasks/
# [] が返れば一覧取得は成功
curl -X POST http://localhost:8000/api/tasks/ \
-H "Content-Type: application/json" \
-d '{"title": "牛乳を買う"}'
# {"id":1,"title":"牛乳を買う","done":false,"created_at":"..."} が 201 で返る
この時点では認証も権限も設定していないため、DRFの既定の権限AllowAnyが効き、誰でもPOSTで書き込めます。JWTの章で塞ぐまで、外部に公開しないでください。URL末尾のスラッシュを落としたPOSTは、DjangoのAPPEND_SLASHによりDEBUG時にRuntimeErrorになります。
CORSの設定と、ViteプロキシでCORS自体を避ける方法
Reactをlocalhost:5173、Djangoをlocalhost:8000で動かすと、ポートが違うだけで別オリジンになり、ブラウザがレスポンスの読み取りを止めます。対処は「Djangoで許可オリジンを返す」か「Viteのプロキシで同一オリジンに見せる」かの2つです。仕組みはCORSとは?仕組み・プリフライト・サーバー設定例・エラー解決で詳しく扱っています。
django-cors-headersの許可オリジン設定とミドルウェアの配置順
django-cors-headers(4.9.0)のREADMEは、CorsMiddlewareをできるだけ上、特にCommonMiddlewareのようにレスポンスを返しうるミドルウェアより前に置くよう指示しています。
# config/settings.py
INSTALLED_APPS = [
# ...
"rest_framework",
"corsheaders",
"tasks",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware", # CommonMiddlewareより前
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
# ...
]
CORS_ALLOWED_ORIGINS = ["http://localhost:5173"]
許可していないオリジンを送ったときの挙動は誤解されやすい点です。実測では、プリフライト(OPTIONS)自体は200で返り、access-control-allow-originヘッダーが付かないだけでした。止めているのはブラウザなので、CORSエラーはブラウザの開発者ツールでレスポンスヘッダーを見て調べます。
# 許可したオリジン
curl -i -X OPTIONS http://localhost:8000/api/tasks/ \
-H "Origin: http://localhost:5173" -H "Access-Control-Request-Method: POST"
# HTTP/1.1 200 OK
# access-control-allow-origin: http://localhost:5173
# 許可していないオリジン
curl -i -X OPTIONS http://localhost:8000/api/tasks/ \
-H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: POST"
# HTTP/1.1 200 OK(access-control-allow-origin が無い)
CORS_ALLOW_ALL_ORIGINS = Trueは手早く動きますが、任意のサイトからの読み取りを許すことになるため本番では使いません。
Viteのserver.proxyによる開発中のAPI呼び出しの同一オリジン化
Viteのserver.proxyに/apiを登録すると、Viteの開発サーバーがその要求をDjangoへ転送します。ブラウザから見るとlocalhost:5173への同一オリジン通信なので、CORSの設定が要りません。
// frontend/vite.config.js
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// /api で始まるリクエストを Django の開発サーバーへ転送する
'/api': 'http://127.0.0.1:8000',
},
},
})
このときDjangoに届くHostヘッダーはlocalhost:5173のままです。DjangoはDEBUG=TrueでALLOWED_HOSTSが空ならlocalhost・127.0.0.1・[::1]を許可するため、そのまま通ります。本番も同一ドメインで配信するならプロキシ、本番でフロントとAPIを別ドメインに置くなら開発段階からdjango-cors-headersを使う、と本番の形に合わせて選ぶと設定漏れに早く気付けます。
認証方式の選び分け:セッション+CSRF・allauth headless・JWT
ReactとDjangoの認証は、本番でフロントとAPIを同じドメインから配信するかどうかで決めます。
| 方式 | 向く配信形態 | CSRF対策 | トークン保管 |
|---|---|---|---|
| セッション(自作API) | 同一ドメイン | 必要・抜けやすい | 不要 |
| allauth.headless(browser) | 同一ドメイン | 必要・組み込み済み | 不要 |
| allauth.headless(app) | モバイル | 不要 | session_token |
| SimpleJWT | 別ドメイン・モバイル共用 | Cookieに置かなければ不要 | アクセスとリフレッシュ |
セッション認証を自作APIで組むときのCSRFの抜け
同一ドメインで配信するなら、Djangoのセッションをそのまま使うのが一番単純です。ただし、ログインAPIをDRFの@api_viewで自作すると、CSRF検証が抜けます。DRFのAPIViewはDjangoのCSRFミドルウェアの対象外として扱われ、SessionAuthenticationがCSRFを確かめるのはログイン済みの要求だけだからです。実測では、X-CSRFTokenヘッダー無しのログイン要求が200で通り、@api_viewのさらに外側に@csrf_protectを重ねても200のままでした。ログイン処理は、Django標準のログインビューか、CSRF検証を備えたallauthのbrowser APIに任せます。
ログイン後の要求は、ヘッダー無しなら403(CSRF token missing)で止まります。さらに、Djangoはログインの瞬間にCSRFトークンを入れ替えるため、ログイン前に読んだトークンを使い回すと403(incorrect)になります。axiosの既定のCookie名はXSRF-TOKENなので、xsrfCookieName: 'csrftoken'とxsrfHeaderName: 'X-CSRFToken'を指定し、毎回Cookieから読ませます。トークンの仕組みはCSRFトークンとは?仕組み・Double Submit CookieとSPAでの実装で確認できます。
allauth.headlessでSPAのログインを組む設定
自作の抜けを避けるなら、django-allauthのSPA・モバイル向けAPIであるallauth.headlessを使います。0.63.0(2024年5月)で入った機能で、現行は65.21.0です。
# pip install "django-allauth[headless]"
# config/settings.py
INSTALLED_APPS += ["allauth", "allauth.account", "allauth.headless"]
MIDDLEWARE += ["allauth.account.middleware.AccountMiddleware"]
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
HEADLESS_ONLY = True # allauth の画面は出さず、APIだけ使う
HEADLESS_FRONTEND_URLS = {
"account_confirm_email": "https://app.example.com/verify-email/{key}",
"account_reset_password_from_key": "https://app.example.com/password/reset/{key}",
"account_signup": "https://app.example.com/signup",
}
# 設定後に python manage.py migrate で allauth のテーブルを作る
# config/urls.py
urlpatterns += [
path("accounts/", include("allauth.urls")),
path("_allauth/", include("allauth.headless.urls")),
]
設定後にmigrateを実行してから、Django 6.1.2で確かめた挙動は次の通りです。GET /_allauth/browser/v1/auth/sessionが未ログインで401を返すと同時にcsrftokenのCookieを発行し、POST /_allauth/browser/v1/auth/loginはCSRFトークン無しなら403、付ければ200でした。モバイル向けの/_allauth/app/v1/auth/loginはCookieを使わず、応答のsession_tokenを以後のヘッダーで送る方式です。browserクライアントのログイン後は通常のDjangoセッションになるため、DRF側はSessionAuthenticationのままで使えます。appクライアントのX-Session-TokenをDRFで受けるには、allauth.headless.contrib.rest_framework.authentication.XSessionTokenAuthenticationを認証クラスに指定します。標準authとallauthの使い分けはDjangoのログイン機能:標準authの範囲とカスタムユーザー・allauthで扱っています。
SimpleJWTのログインとReactでの期限切れトークンの自動更新
フロントとAPIを別ドメインに置く場合や、モバイルアプリと同じAPIを使う場合はJWTを選びます。サーバーはセッションを持たず、ReactがAuthorizationヘッダーでトークンを送ります。
settings.pyでのSimpleJWT認証クラスと権限クラスの指定
# config/settings.py
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}
# config/urls.py に追記
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns += [
path("api/token/", TokenObtainPairView.as_view()),
path("api/token/refresh/", TokenRefreshView.as_view()),
]
認証クラスだけJWTにしても、権限クラスを指定しなければ既定のAllowAnyのままで、トークン無しの要求が通ります。2つ並べて書くのが要点です。/api/token/にユーザー名とパスワードをPOSTするとアクセスとリフレッシュの2つのトークンが返り、トークン無しの/api/tasks/は401とWWW-Authenticate: Bearer realm="api"を返します。SimpleJWTのSettingsの既定はアクセス5分・リフレッシュ1日で、発行されたトークンのexpとiatの差も300秒と86400秒でした。使ったリフレッシュトークンを失効させたい場合は、ROTATE_REFRESH_TOKENSとBLACKLIST_AFTER_ROTATIONをTrueにし、token_blacklistアプリを入れます。
axiosインターセプターによるBearer付与と同時401の更新集約
アクセストークンは5分で切れるため、401を受けたらリフレッシュトークンで再発行し、元の要求を送り直します。よくある実装は401のたびに更新要求を出すので、画面の初期表示で一覧取得と登録が同時に401になると、更新要求も2本飛びます。ROTATE_REFRESH_TOKENSに加えてBLACKLIST_AFTER_ROTATIONとブラックリストアプリを有効にしていると、先の要求で失効したトークンを後の要求が使い、更新に失敗する場合があります。次のコードは、実行中の更新をrefreshingに保持して1回にまとめます。
// frontend/src/api.js
import axios from 'axios'
// 同一オリジン(Viteプロキシ・本番のNginx)なら baseURL は '/api' のままでよい
export const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
const access = localStorage.getItem('access')
if (access) config.headers.Authorization = `Bearer ${access}`
return config
})
let refreshing = null
api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config
const refresh = localStorage.getItem('refresh')
if (error.response?.status !== 401 || original._retry || !refresh) {
return Promise.reject(error)
}
original._retry = true
// 同時に複数の401が返っても、リフレッシュ要求は1回にまとめる
// 更新要求はインターセプターを持たない素のaxiosで送る(401の無限ループ防止)
refreshing ??= axios
.post(`${api.defaults.baseURL}/token/refresh/`, { refresh })
.then((r) => {
localStorage.setItem('access', r.data.access)
// ROTATE_REFRESH_TOKENS=True のときは新しいリフレッシュトークンも返る
if (r.data.refresh) localStorage.setItem('refresh', r.data.refresh)
})
.finally(() => { refreshing = null })
try {
await refreshing
} catch (e) {
localStorage.removeItem('access')
localStorage.removeItem('refresh')
window.dispatchEvent(new Event('auth-expired'))
return Promise.reject(e)
}
return api(original)
},
)
export async function login(username, password) {
const { data } = await axios.post(`${api.defaults.baseURL}/token/`, { username, password })
localStorage.setItem('access', data.access)
localStorage.setItem('refresh', data.refresh)
}
export const fetchTasks = () => api.get('/tasks/').then((r) => r.data)
export const createTask = (title) => api.post('/tasks/', { title }).then((r) => r.data)
アクセストークンの寿命を2秒に縮めて、Viteのプロキシ経由で試しました。期限切れ後に一覧取得と登録を同時に送ると、DjangoのログはGETとPOSTの401が2本、/api/token/refresh/が1本、続いてGETの200とPOSTの201の順でした。ログインと更新の要求はapi.defaults.baseURLを使うので、別ドメイン構成でbaseURLを変えても送り先がずれません。リフレッシュトークンの期限も切れると、保存したトークンを消してauth-expiredイベントを出し、画面側がログインフォームへ戻します。ローテーションと寿命の決め方はリフレッシュトークンとは?トークンローテーションの仕組みと更新フローで解説しています。
useEffectとuseStateによる一覧・登録フォーム・取得状態の表示
// frontend/src/App.jsx
import { useEffect, useState } from 'react'
import { createTask, fetchTasks, login } from './api'
function TaskList() {
const [tasks, setTasks] = useState([])
const [title, setTitle] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [submitError, setSubmitError] = useState(null)
useEffect(() => {
let ignore = false
fetchTasks()
.then((data) => { if (!ignore) setTasks(data) })
.catch((e) => { if (!ignore) setError(e.message) })
.finally(() => { if (!ignore) setLoading(false) })
return () => { ignore = true }
}, [])
async function handleSubmit(e) {
e.preventDefault()
try {
const task = await createTask(title)
setTasks((prev) => [task, ...prev])
setTitle('')
setSubmitError(null)
} catch (err) {
setSubmitError(err.message)
}
}
if (loading) return <p>読み込み中…</p>
if (error) return <p>取得に失敗しました:{error}</p>
return (
<>
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button disabled={!title}>追加</button>
</form>
{submitError && <p>登録に失敗しました:{submitError}</p>}
<ul>{tasks.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
</>
)
}
export default function App() {
const [authed, setAuthed] = useState(!!localStorage.getItem('access'))
const [loginError, setLoginError] = useState(null)
// api.js がリフレッシュに失敗したらログイン画面へ戻す
useEffect(() => {
const onExpired = () => setAuthed(false)
window.addEventListener('auth-expired', onExpired)
return () => window.removeEventListener('auth-expired', onExpired)
}, [])
async function handleLogin(e) {
e.preventDefault()
const form = new FormData(e.currentTarget)
try {
await login(form.get('username'), form.get('password'))
setAuthed(true)
} catch {
setLoginError('ユーザー名かパスワードが違います')
}
}
if (authed) return <TaskList />
return (
<form onSubmit={handleLogin}>
<input name="username" />
<input name="password" type="password" />
<button>ログイン</button>
{loginError && <p>{loginError}</p>}
</form>
)
}
useEffectの第2引数を空配列にすると、初回表示で一覧を取得します。ignoreフラグは、開発モードで2回走ったエフェクトの1回目の結果が後から届いて、状態を上書きするのを防ぎます。登録とログインの失敗は、画面を消さずにフォームの下へ理由を出します。
トークン保存先の比較:localStorageとhttpOnly Cookie
上の例はlocalStorageに保存しています。XSSが1か所でもあればトークンを読み出されるため、外部公開のサービスではリフレッシュトークンをhttpOnly Cookieに置き、アクセストークンはメモリだけに持つ方が安全です。ただしCookieに置いた時点でCSRF対策が再び必要になるため、同一ドメインで配信できるなら、JWTをCookieに入れるよりallauth.headlessのセッションを使う方が設定は少なく済みます。
本番デプロイ構成:同一ドメイン配信と別ドメイン配信の設定差分
開発時の構成のまま本番に出すと、許可オリジンがlocalhostのまま残ります。配信形態ごとに変える設定は次の通りです。
NginxによるReactのビルド成果物とDjango APIの同一ドメイン配信
npm run buildで生成したdistをNginxが配信し、/api/をgunicornで動くDjangoへ中継します。ブラウザからは同一オリジンなので、CORSの設定は要りません。allauth.headlessを使う場合は、ViteとNginxの両方で/_allauth/もDjangoへの転送対象に加えます。
server {
listen 443 ssl;
server_name app.example.com;
# ssl_certificate と ssl_certificate_key は http コンテキストで設定済みの前提
root /var/www/frontend/dist;
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri /index.html; # React側のルーティングで直接開いたURLも返す
}
}
try_filesの最後を/index.htmlにしないと、React Routerで作った/tasks/1のようなURLを再読み込みしたときに404になります。gunicornの設定やDEBUG・ALLOWED_HOSTSなど本番設定の一式はDjangoのデプロイ手順:gunicorn+nginx構成と本番設定を参照してください。
フロントとAPIを別ドメインに置く場合のCORS・CSRF設定
Reactを静的ホスティング(app.example.com)、DjangoをAPIサーバー(api.example.com)に分ける場合は、許可オリジンを本番のフロントに置き換えます。JWTをヘッダーで送るだけならCORS_ALLOWED_ORIGINSの1か所で済みます。セッションやCookieを併用する場合は、Django側の2設定とaxios側のwithCredentialsが加わり、CSRFトークンの受け渡しも作り直しになります。api.example.comが発行したcsrftokenのCookieはapp.example.comのJavaScriptから読めず、axiosも既定では別オリジンへX-CSRFTokenを自動で付けないためです。APIがget_token(request)の値をJSONで返し、React側が更新系の要求でヘッダーに入れ、ログイン後に取り直す実装が要ります。
# config/settings.py(フロント app.example.com/API api.example.com)
CORS_ALLOWED_ORIGINS = ["https://app.example.com"]
# 以下はセッションやCookieを併用する場合だけ
CORS_ALLOW_CREDENTIALS = True
CSRF_TRUSTED_ORIGINS = ["https://app.example.com"]
// frontend/src/api.js
export const api = axios.create({
baseURL: 'https://api.example.com/api',
withCredentials: true,
})
Django 6.1のCSRF_TRUSTED_ORIGINSの既定は空リストで、別オリジンから送られたPOSTは、Originヘッダーがここに無いと403になります。設定が3か所に分かれるため、Cookieを使うなら同一ドメイン配信に寄せる方が事故は減ります。
よくある質問
ReactとDjangoの認証は何を使えばよいですか?
本番で同じドメインから配信するならセッション認証で、ログインAPIはallauth.headlessのbrowserクライアントを使うのが安全です。フロントとAPIが別ドメインか、モバイルアプリと同じAPIを使うならSimpleJWTのJWTを使います。どちらでもDEFAULT_PERMISSION_CLASSESの指定は必須です。
Djangoだけでフロントエンドも作れますか?
作れます。テンプレートでHTMLを返すのがDjangoの標準で、部分更新はhtmxで足ります。Reactを選ぶのは、Web画面の状態や操作が複雑になる場合です。モバイルアプリとAPIを共用するだけなら、Web画面までReactにする必要はありません。
CORSエラーが消えないときは何を確認しますか?
ブラウザの開発者ツールで、レスポンスにaccess-control-allow-originが付いているかを見ます。付いていなければ、CORS_ALLOWED_ORIGINSの値がスキームとポートまで一致しているか、CorsMiddlewareがCommonMiddlewareより前にあるかを確かめます。許可外のオリジンでもプリフライトは200で返るので、ステータスだけ見ても原因は分かりません。
useEffectでDjangoのAPIが2回呼ばれるのは不具合ですか?
開発モードのStrictModeによる意図的な挙動で、本番ビルドでは1回です。2回目の呼び出しで画面が崩れるなら、エフェクトの後始末(ignoreフラグやAbortController)が足りていないサインです。
Django 5.2 LTSの既存案件でSimpleJWTを使い続けてよいですか?
5.2は2028年4月までセキュリティ問題とデータ損失バグの修正が出るので、使い続けること自体は問題ありません。ただし通常サポートは2025年12月3日に終わっているため、6.2 LTS(2027年4月予定)への移行計画は立てておきます。SimpleJWTを5.5.1に上げるとuser_idクレームが文字列になる点だけは、フロント側のコードを確かめてください。