PythonのDjangoでバックエンドを構築し、Vue.jsでリッチなフロントエンドを実現する構成は、2026年現在も多くのプロダクトで採用されています。Django側の堅牢なORM・認証基盤と、Vue.jsのリアクティブなUI構築能力を掛け合わせることで、保守性と開発体験を両立できます。

この記事ではDjango REST Framework(DRF)を用いたバックエンドAPIの構築から、Vue 3 Composition APIによるフロントエンド実装、JWT認証、CORS設定、さらにDocker化やテスト戦略まで、本番運用を見据えた開発フローを網羅的に扱います。

Vue.jsとDjangoを組み合わせるメリットと構成パターン

なぜDjango + Vue.jsが選ばれるのか

DjangoはPython製のフルスタックWebフレームワークで、Admin管理画面・ORM・マイグレーション・認証機構がデフォルトで揃っています。一方のVue.jsは軽量かつ学習コストが低いJavaScriptフレームワークで、段階的導入が可能です。

両者を組み合わせる主な利点は以下のとおりです。

観点Django単体Django + Vue.js
UI表現力テンプレートエンジン依存SPAによるリッチUI
開発分業フルスタック1名フロント/バックの分業が容易
API再利用性テンプレート密結合モバイルアプリなど他クライアントと共有可能
リアルタイム性限定的WebSocket・SSE連携が自然
既存資産の活用Admin・ORM活用可Djangoの認証・権限をそのまま活用

3つの連携パターン

DjangoとVue.jsの連携方法は大きく3つに分かれます。

パターン1: Djangoテンプレート内にVue.jsを部分適用

Djangoのテンプレートエンジンが描画するHTMLの一部にVue.jsをマウントする方法です。既存のDjangoプロジェクトに段階的にリアクティブUIを追加したいケースに適しています。

# views.py
from django.shortcuts import render

def product_list(request):
    products = Product.objects.all()
    return render(request, 'products/list.html', {
        'products_json': json.dumps(list(products.values()))
    })
<!-- list.html -->
<div id="product-app">
  <product-filter :initial-products="products"></product-filter>
</div>
<script>
  const app = Vue.createApp({
    data() {
      return {
        products: JSON.parse('{{ products_json|escapejs }}')
      }
    }
  })
  app.mount('#product-app')
</script>

パターン2: Django REST Framework + Vue.js SPA(完全分離)

フロントエンドとバックエンドを完全に分離し、DRFが提供するREST APIを介して通信します。最も一般的な構成であり、本記事の主軸です。

project-root/
├── backend/          # Django + DRF
│   ├── manage.py
│   ├── config/       # settings, urls
│   └── api/          # DRFアプリ
└── frontend/         # Vue 3 + Vite
    ├── src/
    ├── package.json
    └── vite.config.ts

パターン3: Django + django-vite による統合ビルド

django-viteパッケージを使うと、Viteのビルド成果物をDjangoの静的ファイルとして配信できます。別サーバーを立てる必要がなく、Djangoの認証・セッション機構をそのまま利用可能です。

# settings.py
DJANGO_VITE = {
    "default": {
        "dev_mode": DEBUG,
        "dev_server_host": "localhost",
        "dev_server_port": 5173,
    }
}

米国のDjango開発コミュニティでは「API不要な統合パターン(パターン1・3)」も積極的に議論されています。StackOverflowやDjango公式フォーラムでは「小規模プロジェクトならDRFを経由せずDjangoテンプレートにVue.jsを埋め込むほうがシンプル」という意見が多く見られます。プロジェクト規模と要件に応じた選定が重要です。

開発環境のセットアップ

前提条件

  • Python 3.11以降
  • Node.js 20 LTS以降
  • npm または yarn

Djangoバックエンドの構築

# 仮想環境の作成と有効化
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate

# パッケージのインストール
pip install django djangorestframework django-cors-headers djangorestframework-simplejwt

# Djangoプロジェクトの作成
django-admin startproject config .
python manage.py startapp api

config/settings.pyに必要な設定を追加します。

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # サードパーティ
    'rest_framework',
    'corsheaders',
    # 自前アプリ
    'api',
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',  # 最上位に配置
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

Vue.jsフロントエンドの構築

npm create vite@latest frontend -- --template vue-ts
cd frontend
npm install
npm install axios vue-router@4 pinia

ViteのデフォルトテンプレートにはTypeScriptが含まれます。Vue 3のComposition APIとTypeScriptを組み合わせることで、IDEの補完が効き、型安全な開発体験を得られます。

CORS設定とAPI通信の基本

DjangoのCORS設定

フロントエンド(http://localhost:5173)とバックエンド(http://localhost:8000)が異なるオリジンで動作するため、CORS(Cross-Origin Resource Sharing)の許可設定が必要です。

# config/settings.py

# 開発環境
CORS_ALLOWED_ORIGINS = [
    "http://localhost:5173",
    "http://127.0.0.1:5173",
]

# 本番環境では具体的なドメインを指定
# CORS_ALLOWED_ORIGINS = ["https://your-domain.com"]

CORS_ALLOW_CREDENTIALS = True

CORS_ALLOW_ALL_ORIGINS = Trueは開発時の一時的な対処としてのみ使用し、本番環境では必ず具体的なオリジンを指定します。

axiosによるAPI通信の共通設定

Vue.js側でaxiosのインスタンスを作成し、ベースURLやインターセプターを一元管理します。

// frontend/src/lib/api.ts
import axios from 'axios'

const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api',
  headers: {
    'Content-Type': 'application/json',
  },
})

// リクエストインターセプター: JWTトークンを自動付与
apiClient.interceptors.request.use((config) => {
  const token = localStorage.getItem('access_token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

// レスポンスインターセプター: 401時のトークンリフレッシュ
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      // トークンリフレッシュ処理(後述)
    }
    return Promise.reject(error)
  }
)

export default apiClient

Django REST FrameworkでCRUD APIを構築する

モデルの定義

タスク管理アプリを例にとり、モデル・シリアライザ・ビューセットを順に実装します。

# api/models.py
from django.db import models
from django.contrib.auth.models import User


class Task(models.Model):
    class Status(models.TextChoices):
        TODO = 'todo', '未着手'
        IN_PROGRESS = 'in_progress', '進行中'
        DONE = 'done', '完了'

    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.TODO,
    )
    owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tasks')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        return self.title

シリアライザの作成

# api/serializers.py
from rest_framework import serializers
from .models import Task


class TaskSerializer(serializers.ModelSerializer):
    owner_name = serializers.ReadOnlyField(source='owner.username')

    class Meta:
        model = Task
        fields = ['id', 'title', 'description', 'status', 'owner', 'owner_name', 'created_at', 'updated_at']
        read_only_fields = ['owner', 'created_at', 'updated_at']

ビューセットとルーティング

# api/views.py
from rest_framework import viewsets, permissions
from .models import Task
from .serializers import TaskSerializer


class TaskViewSet(viewsets.ModelViewSet):
    serializer_class = TaskSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        return Task.objects.filter(owner=self.request.user)

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)
# api/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TaskViewSet

router = DefaultRouter()
router.register(r'tasks', TaskViewSet, basename='task')

urlpatterns = [
    path('', include(router.urls)),
]
# config/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),
]

マイグレーションを実行してテーブルを作成します。

python manage.py makemigrations
python manage.py migrate

この時点で http://localhost:8000/api/tasks/ にアクセスすると、DRFのブラウザブルAPIが表示されます。

JWT認証の実装

Django側の設定

djangorestframework-simplejwtを使用します。

# config/settings.py
from datetime import timedelta

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
}

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
}
# config/urls.py
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]

Vue.js側の認証ストア(Pinia)

Vue 3では状態管理にPiniaが推奨されています。Vuexの後継として設計されており、TypeScript対応・軽量・直感的なAPIが特徴です。

// frontend/src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import apiClient from '@/lib/api'

export const useAuthStore = defineStore('auth', () => {
  const accessToken = ref<string | null>(localStorage.getItem('access_token'))
  const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'))

  const isAuthenticated = computed(() => !!accessToken.value)

  async function login(username: string, password: string) {
    const response = await apiClient.post('/token/', { username, password })
    accessToken.value = response.data.access
    refreshToken.value = response.data.refresh
    localStorage.setItem('access_token', response.data.access)
    localStorage.setItem('refresh_token', response.data.refresh)
  }

  async function refresh() {
    if (!refreshToken.value) throw new Error('No refresh token')
    const response = await apiClient.post('/token/refresh/', {
      refresh: refreshToken.value,
    })
    accessToken.value = response.data.access
    localStorage.setItem('access_token', response.data.access)
    if (response.data.refresh) {
      refreshToken.value = response.data.refresh
      localStorage.setItem('refresh_token', response.data.refresh)
    }
  }

  function logout() {
    accessToken.value = null
    refreshToken.value = null
    localStorage.removeItem('access_token')
    localStorage.removeItem('refresh_token')
  }

  return { accessToken, refreshToken, isAuthenticated, login, refresh, logout }
})

トークンリフレッシュの自動化

axiosのレスポンスインターセプターでアクセストークンの期限切れを検知し、自動的にリフレッシュします。

// frontend/src/lib/api.ts(レスポンスインターセプターの完全版)
import { useAuthStore } from '@/stores/auth'

let isRefreshing = false
let failedQueue: Array<{
  resolve: (value: unknown) => void
  reject: (reason?: unknown) => void
}> = []

const processQueue = (error: unknown) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error)
    } else {
      prom.resolve(undefined)
    }
  })
  failedQueue = []
}

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject })
        }).then(() => apiClient(originalRequest))
      }

      originalRequest._retry = true
      isRefreshing = true

      const authStore = useAuthStore()
      try {
        await authStore.refresh()
        processQueue(null)
        return apiClient(originalRequest)
      } catch (refreshError) {
        processQueue(refreshError)
        authStore.logout()
        window.location.href = '/login'
        return Promise.reject(refreshError)
      } finally {
        isRefreshing = false
      }
    }
    return Promise.reject(error)
  }
)

米国のWebアプリ開発コミュニティでは、このようなサイレント認証(ページリフレッシュ時のトークン自動更新)パターンが標準的に実装されています。セッション途中で認証が切れるUX劣化を防ぐため、本番環境では必ず取り入れるべき設計です。

Vue 3 Composition APIによるフロントエンド実装

ルーティング設定

// frontend/src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const routes = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/LoginView.vue'),
  },
  {
    path: '/',
    name: 'TaskList',
    component: () => import('@/views/TaskListView.vue'),
    meta: { requiresAuth: true },
  },
  {
    path: '/tasks/:id',
    name: 'TaskDetail',
    component: () => import('@/views/TaskDetailView.vue'),
    meta: { requiresAuth: true },
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

router.beforeEach((to) => {
  const authStore = useAuthStore()
  if (to.meta.requiresAuth && !authStore.isAuthenticated) {
    return { name: 'Login' }
  }
})

export default router

タスク一覧のコンポーネント

Composition APIの<script setup>構文を使い、テンプレートとロジックを簡潔に記述します。

<!-- frontend/src/views/TaskListView.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import apiClient from '@/lib/api'

interface Task {
  id: number
  title: string
  description: string
  status: 'todo' | 'in_progress' | 'done'
  owner_name: string
  created_at: string
}

const tasks = ref<Task[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const newTitle = ref('')

async function fetchTasks() {
  loading.value = true
  error.value = null
  try {
    const response = await apiClient.get<Task[]>('/tasks/')
    tasks.value = response.data
  } catch (e) {
    error.value = 'タスクの取得に失敗しました'
  } finally {
    loading.value = false
  }
}

async function addTask() {
  if (!newTitle.value.trim()) return
  try {
    const response = await apiClient.post<Task>('/tasks/', {
      title: newTitle.value,
    })
    tasks.value.unshift(response.data)
    newTitle.value = ''
  } catch (e) {
    error.value = 'タスクの追加に失敗しました'
  }
}

async function updateStatus(task: Task, status: Task['status']) {
  try {
    await apiClient.patch(`/tasks/${task.id}/`, { status })
    task.status = status
  } catch (e) {
    error.value = 'ステータスの更新に失敗しました'
  }
}

async function deleteTask(id: number) {
  try {
    await apiClient.delete(`/tasks/${id}/`)
    tasks.value = tasks.value.filter((t) => t.id !== id)
  } catch (e) {
    error.value = 'タスクの削除に失敗しました'
  }
}

onMounted(fetchTasks)
</script>

<template>
  <div class="task-list">
    <h1>タスク一覧</h1>
    <div v-if="error" class="error">{{ error }}</div>

    <form @submit.prevent="addTask" class="add-form">
      <input v-model="newTitle" placeholder="新しいタスク" />
      <button type="submit">追加</button>
    </form>

    <div v-if="loading">読み込み中...</div>
    <ul v-else>
      <li v-for="task in tasks" :key="task.id" class="task-item">
        <div>
          <strong>{{ task.title }}</strong>
          <span class="status" :class="task.status">{{ task.status }}</span>
        </div>
        <div class="actions">
          <select :value="task.status" @change="updateStatus(task, ($event.target as HTMLSelectElement).value as Task['status'])">
            <option value="todo">未着手</option>
            <option value="in_progress">進行中</option>
            <option value="done">完了</option>
          </select>
          <button @click="deleteTask(task.id)">削除</button>
        </div>
      </li>
    </ul>
  </div>
</template>

ファイルアップロードの実装

タスクへの添付ファイル機能を例に、バイナリデータのやり取りを実装します。

Django側

# api/models.py(追加)
class TaskAttachment(models.Model):
    task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='attachments')
    file = models.FileField(upload_to='attachments/%Y/%m/')
    uploaded_at = models.DateTimeField(auto_now_add=True)
# api/serializers.py(追加)
class TaskAttachmentSerializer(serializers.ModelSerializer):
    class Meta:
        model = TaskAttachment
        fields = ['id', 'task', 'file', 'uploaded_at']
# api/views.py(追加)
from rest_framework.parsers import MultiPartParser, FormParser

class TaskAttachmentViewSet(viewsets.ModelViewSet):
    serializer_class = TaskAttachmentSerializer
    parser_classes = [MultiPartParser, FormParser]
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        return TaskAttachment.objects.filter(task__owner=self.request.user)

Vue.js側

// ファイルアップロード関数
async function uploadFile(taskId: number, file: File) {
  const formData = new FormData()
  formData.append('file', file)
  formData.append('task', String(taskId))

  const response = await apiClient.post('/attachments/', formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
  })
  return response.data
}

Reactとの比較: Djangoとの相性

Djangoのフロントエンドとして、Vue.js以外にReactも候補になります。プロジェクトの特性に応じた選定基準を整理します。

比較項目Vue.jsReact
学習曲線緩やか(HTML拡張ベース)やや急(JSX独自記法)
テンプレート構文Djangoテンプレートとの親和性が高いJSXのため分離が明確
段階的導入CDN1行で既存ページに追加可能ビルドツール前提
エコシステム規模中規模(実用十分)大規模(npm packages豊富)
TypeScript対応Vue 3でネイティブ対応元々TypeScriptと親和的
状態管理Pinia(公式推奨)Redux / Zustand / Jotai など複数
日本語ドキュメント公式翻訳が充実コミュニティ翻訳中心
求人数(国内)React比で少ないフロントエンド求人の主流

Djangoテンプレートの一部にリアクティブUIを埋め込む用途(パターン1)ではVue.jsが圧倒的に導入しやすいです。一方、大規模SPAで多数のサードパーティライブラリを活用したい場合はReactのエコシステムが有利です。

Docker化による開発環境の統一

チーム開発では「手元で動かない」問題を防ぐため、Docker Composeで環境を統一します。

# docker-compose.yml
services:
  backend:
    build: ./backend
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - ./backend:/app
    ports:
      - "8000:8000"
    environment:
      - DEBUG=1
      - DATABASE_URL=postgres://postgres:postgres@db:5432/app
    depends_on:
      - db

  frontend:
    build: ./frontend
    command: npm run dev -- --host 0.0.0.0
    volumes:
      - ./frontend:/app
      - /app/node_modules
    ports:
      - "5173:5173"

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
# backend/Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# frontend/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
docker compose up -d

これでバックエンド・フロントエンド・データベースが一括で起動します。新しいチームメンバーが参画した際もdocker compose upだけで開発環境が再現されます。

テスト戦略

Django APIのテスト

DRFにはAPIテスト用のクライアントが付属しています。

# api/tests.py
from django.contrib.auth.models import User
from rest_framework.test import APITestCase
from rest_framework import status
from .models import Task


class TaskAPITest(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(username='testuser', password='testpass')
        self.client.force_authenticate(user=self.user)

    def test_create_task(self):
        response = self.client.post('/api/tasks/', {'title': 'テストタスク'})
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Task.objects.count(), 1)
        self.assertEqual(Task.objects.first().owner, self.user)

    def test_list_tasks_only_own(self):
        Task.objects.create(title='自分のタスク', owner=self.user)
        other_user = User.objects.create_user(username='other', password='pass')
        Task.objects.create(title='他人のタスク', owner=other_user)

        response = self.client.get('/api/tasks/')
        self.assertEqual(len(response.data), 1)
        self.assertEqual(response.data[0]['title'], '自分のタスク')

    def test_unauthenticated_access(self):
        self.client.force_authenticate(user=None)
        response = self.client.get('/api/tasks/')
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
python manage.py test api

Vue.jsコンポーネントのテスト

VitestとVue Test Utilsで単体テストを実行します。

npm install -D vitest @vue/test-utils jsdom
// frontend/src/views/__tests__/TaskListView.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import TaskListView from '../TaskListView.vue'

vi.mock('@/lib/api', () => ({
  default: {
    get: vi.fn().mockResolvedValue({
      data: [
        { id: 1, title: 'テスト', status: 'todo', owner_name: 'user', created_at: '2026-01-01' }
      ]
    }),
    post: vi.fn(),
    patch: vi.fn(),
    delete: vi.fn(),
  }
}))

describe('TaskListView', () => {
  it('タスク一覧が表示される', async () => {
    const wrapper = mount(TaskListView, {
      global: {
        plugins: [createPinia()],
      },
    })
    await vi.dynamicImportSettled()
    expect(wrapper.text()).toContain('テスト')
  })
})

本番デプロイ時の構成

推奨インフラ構成

本番環境ではフロントエンドのビルド成果物を静的ファイルとしてCDNから配信し、バックエンドAPIはGunicorn + Nginxで運用します。

[ユーザー] → [CDN / Nginx]
               ├── /           → Vue.js SPA(静的ファイル)
               └── /api/       → Gunicorn(Django)
                                   └── PostgreSQL

Vue.jsのビルドとDjango静的ファイルの統合

フロントエンドのビルド成果物をDjangoのstaticfilesとして配信する方法です。

# フロントエンドのビルド
cd frontend
npm run build
# dist/ にビルド成果物が生成される
# config/settings.py(本番用)
import os

STATIC_URL = '/static/'
STATICFILES_DIRS = [
    os.path.join(BASE_DIR, 'frontend', 'dist'),
]
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')

環境変数の管理

開発環境と本番環境で異なる設定値を環境変数で切り替えます。

# frontend/.env.production
VITE_API_BASE_URL=https://api.your-domain.com/api
# config/settings.py
import os
DEBUG = os.environ.get('DEBUG', '0') == '1'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')

htmxという選択肢: DRFを使わないインタラクティブUI

Django + Vue.js(DRF経由)はSPA構築の定番ですが、2024年以降、htmxを使う軽量アプローチも注目されています。htmxはHTMLの属性だけでAJAXリクエストを宣言的に記述でき、JavaScriptのビルドツールが不要です。

比較項目Django + Vue.js (DRF)Django + htmx
ビルドツールVite / webpack 必要不要
JavaScript記述量多い最小限
SEOSSR or プリレンダリング必要テンプレート描画で対応
リアルタイムUIWebSocket連携SSE / WebSocket
適用規模中〜大規模SPA小〜中規模
学習コストVue.js + DRF + axioshtmx属性のみ

htmxはDjango公式フォーラムでも話題が増えており、検索ボリューム「Django-htmx」は月間50件に達しています。小規模プロジェクトや管理画面のインタラクティブ化にはhtmxを、ユーザー向けのリッチUIにはVue.jsを、という使い分けが実践的です。

よくあるトラブルと対処法

CORS関連エラー

症状: Access to XMLHttpRequest at 'http://localhost:8000/api/' from origin 'http://localhost:5173' has been blocked by CORS policy

対処: django-cors-headersのインストールと設定を確認します。MIDDLEWARE内でCorsMiddlewareCommonMiddlewareより上に配置されていることが重要です。

CSRFトークンエラー

症状: POSTリクエストで403 Forbidden - CSRF verification failed

対処: DRFのJWT認証を使用している場合、SessionAuthenticationを無効化するか、@csrf_exemptを適用します。JWT認証ではCSRFトークンは不要です。

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
        # SessionAuthentication を含めないことでCSRF不要に
    ],
}

Viteのプロキシ設定

開発時にCORS問題を回避する代替手段として、Viteのプロキシ機能があります。

// frontend/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      },
    },
  },
  resolve: {
    alias: {
      '@': '/src',
    },
  },
})

この設定により、フロントエンドから/api/tasks/へリクエストすると、Vite開発サーバーがhttp://localhost:8000/api/tasks/に転送します。オリジンが同一になるためCORS設定が不要になり、開発体験が向上します。

まとめ

DjangoとVue.jsの組み合わせは、バックエンドの堅牢さとフロントエンドの柔軟なUI構築を両立する構成です。

プロジェクト規模に応じた最適な連携パターンを以下に整理します。

  • 小規模・既存Djangoへの部分追加 → Djangoテンプレート内にVue.jsを埋め込み(CDN利用)
  • 中規模・新規SPA開発 → DRF + Vue 3 + Pinia + TypeScriptの完全分離構成
  • 管理画面のインタラクティブ化 → Django + htmxが軽量で効果的
  • 大規模・マイクロサービス指向 → DRF APIをVue.jsだけでなくモバイルアプリからも呼び出す設計

実装に際して押さえるべきポイントは、CORS設定・JWT認証のトークンリフレッシュ・テスト自動化・Docker化の4点です。これらを初期段階から整備しておくことで、チーム開発でのスケーラビリティを確保できます。