---
title: "ReactとDjangoの連携手順｜DRF・CORS・認証の実装とフロントエンド構成の選び方【Django 6.1】"
url: "https://www.issoh.co.jp/tech/details/4421/"
published: 2024-12-06
updated: 2026-10-29
categories: ["Django"]
publisher: "株式会社一創"
---

# ReactとDjangoの連携手順｜DRF・CORS・認証の実装とフロントエンド構成の選び方【Django 6.1】

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で解説](/tech/details/3275/)で扱っています。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連携まで解説](/tech/details/15371/)にまとめています。この範囲で済むなら、React・ビルド・CORS・トークン管理を抱えるのは保守の手間が増えるだけです。

### 既存テンプレートの一部画面にReactを埋め込むdjango-vite構成

既存のDjangoアプリのダッシュボードだけをReactにしたい場合は、Djangoが返すHTMLにViteのビルド成果物を読み込みます。同一オリジンなのでCORSは要らず、ログインも既存のセッションのままです。読み込みタグを生成する[django-vite](https://github.com/MrBin99/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への値渡し](/tech/details/17109/)を参照してください。

### React SPA＋DRFに分離すべき条件と、採用しない場面

ReactをViteで作った独立アプリにし、DjangoはJSON APIだけを返す構成です。画面遷移が多く状態を持つSPAを作る場合や、フロントとバックを別チームで開発する体制なら、この構成を選びます。iOS・Androidアプリと同じAPIを使う計画があれば、DRFで作ったAPIをそのまま共用できます。Django ORMで定義した既存のモデルをAPIでも再利用できること、Djangoの管理画面を社内向けのデータ登録画面として初日から使えることが、FastAPIなどではなくDjangoを選ぶ根拠になります。どちらも要らない新規APIなら[React×FastAPI連携の手順](/tech/details/2242/)のようにFastAPIで組む方が軽く、Djangoのまま型定義と非同期を重視するならDRFの代わりに[Django Ninja](/tech/details/3278/)を使う手もあります。

逆に、検索流入が要る商品ページやメディアには、ViteのSPA単体は使いません。クライアント側で描画するため、初回のHTMLに本文が入らないからです。その場合はDjangoテンプレートで返すか、SSRができるNext.jsを前段に置きます。既存システムとの連携やモバイルとのAPI共用まで含めて設計したい場合は、一創の[API開発・システム連携](/service/system/api/)でDRFを使ったAPI設計からご相談いただけます。以降は、React SPA＋DRF構成を前提に手順を進めます。

## 検証環境の用意：Django 6.1.2とVite 8の起動手順

作業用ディレクトリの直下に`backend`と`frontend`を並べて立ち上げます。版によってJWTライブラリの対応表記が変わるため、先に版を決めます。

### Djangoの版選びとSimpleJWT 5.5.1の対応表記と実際の動作

[Django公式のダウンロードページ](https://www.djangoproject.com/download/)では、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とサポート期限一覧](/tech/details/17093/)で整理しています。

迷いやすいのが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の変更履歴](https://github.com/jazzband/djangorestframework-simplejwt/blob/master/CHANGELOG.md)にある通り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公式ブログ](https://react.dev/blog/2025/02/14/sunsetting-create-react-app)は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の変更点](/tech/details/3587/)にあります。

## DRFによるTaskモデルのCRUD API作成とcurlでの応答確認

モデル、シリアライザー、ViewSetの順に書きます。DRFの全体像は[Django REST Framework（DRF）とは？3.18の最小構成と本番前に変える設定](/tech/details/3277/)で補足しています。

### 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（シリアライザ）とは？](/tech/details/3276/)で扱っています。

### 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](https://www.django-rest-framework.org/api-guide/permissions/)が効き、誰でもPOSTで書き込めます。JWTの章で塞ぐまで、外部に公開しないでください。URL末尾のスラッシュを落としたPOSTは、Djangoの`APPEND_SLASH`によりDEBUG時に`RuntimeError`になります。

## CORSの設定と、ViteプロキシでCORS自体を避ける方法

Reactを`localhost:5173`、Djangoを`localhost:8000`で動かすと、ポートが違うだけで別オリジンになり、ブラウザがレスポンスの読み取りを止めます。対処は「Djangoで許可オリジンを返す」か「Viteのプロキシで同一オリジンに見せる」かの2つです。仕組みは[CORSとは？仕組み・プリフライト・サーバー設定例・エラー解決](/tech/details/13763/)で詳しく扱っています。

### django-cors-headersの許可オリジン設定とミドルウェアの配置順

[django-cors-headers（4.9.0）のREADME](https://github.com/adamchainz/django-cors-headers)は、`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](https://vite.dev/config/server-options#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での実装](/column/details/3984/)で確認できます。

### allauth.headlessでSPAのログインを組む設定

自作の抜けを避けるなら、django-allauthのSPA・モバイル向けAPIである[allauth.headless](https://docs.allauth.org/en/latest/headless/introduction.html)を使います。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](/tech/details/17099/)で扱っています。

## 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](https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html)の既定はアクセス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`イベントを出し、画面側がログインフォームへ戻します。ローテーションと寿命の決め方は[リフレッシュトークンとは？トークンローテーションの仕組みと更新フロー](/tech/details/16409/)で解説しています。

### 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構成と本番設定](/tech/details/17087/)を参照してください。

### フロントと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`クレームが文字列になる点だけは、フロント側のコードを確かめてください。

## 関連記事

- [Django REST Framework（DRF）とは？3.18の最小構成と本番前に変える設定・使いどころ](/tech/details/3277/)
- [Djangoのログイン機能：標準authの範囲とカスタムユーザー・allauth](/tech/details/17099/)
- [CORSとは？仕組み・プリフライト・サーバー設定例・エラー解決を実装目線で解説](/tech/details/13763/)
- [Djangoのデプロイ手順：gunicorn＋nginx構成と本番設定・配置先の選び方](/tech/details/17087/)
- [React×FastAPI連携の手順｜CORS・Viteプロキシ・型生成とDocker Composeで動かす【2026年版】](/tech/details/2242/)

---

出典: [ReactとDjangoの連携手順｜DRF・CORS・認証の実装とフロントエンド構成の選び方【Django 6.1】](<https://www.issoh.co.jp/tech/details/4421/>)（株式会社一創）
