Lesson 4: Connecting Django and Vue.js
Connecting Django and Vue.js
This tutorial shows how to connect a Vue 3 (Vite) frontend to a Django REST Framework backend that uses Djoser + SimpleJWT for auth.
You’ll get:
- A Django API with CORS enabled
- JWT auth (login, refresh)
- A Vue SPA that stores tokens safely and calls protected endpoints
- Dev & prod configurations that work locally, in Docker, and on Fly.io
🧩 Prerequisites
- Completed the previous posts:
- DRF API (quotes example works)
- Auth with Djoser + SimpleJWT (Updated)
- Python 3.11+, Node 18+, Docker (optional)
Backend assumed running at: http://127.0.0.1:8000
Frontend will run at: http://127.0.0.1:5173
1) Enable CORS on Django
Install and configure CORS:
pip install django-cors-headers
settings.py:
INSTALLED_APPS = [
# ...
"corsheaders",
"rest_framework",
"djoser",
"rest_framework_simplejwt",
"rest_framework_simplejwt.token_blacklist",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
# ...
]
# Allow your Vite dev server
CORS_ALLOWED_ORIGINS = [
"http://127.0.0.1:5173",
"http://localhost:5173",
]
# If you need cookies (not mandatory for token auth)
CSRF_TRUSTED_ORIGINS = ["http://127.0.0.1:5173", "http://localhost:5173"]
Restart Django.
2) Create the Vue 3 App (Vite)
npm create vite@latest vue-client -- --template vue
cd vue-client
npm install
npm install axios pinia vue-router
Optional UI libs:
npm install @tailwindcss/forms tailwindcss postcss autoprefixer
Initialize Tailwind (optional):
npx tailwindcss init -p
tailwind.config.cjs (if using Tailwind):
export default {
content: ["./index.html", "./src/**/*.{vue,js,ts}"],
theme: { extend: {} },
plugins: [require("@tailwindcss/forms")],
};
src/main.ts or src/main.js:
import { createApp } from "vue";
import { createPinia } from "pinia";
import router from "./router";
import App from "./App.vue";
import "./style.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
3) Router & Protected Routes
src/router/index.ts (or .js):
import { createRouter, createWebHistory } from "vue-router";
import Home from "../pages/Home.vue";
import Login from "../pages/Login.vue";
import Dashboard from "../pages/Dashboard.vue";
import { useAuthStore } from "../stores/auth";
const routes = [
{ path: "/", name: "home", component: Home },
{ path: "/login", name: "login", component: Login },
{ path: "/dashboard", name: "dashboard", component: Dashboard, meta: { requiresAuth: true } },
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: "login", query: { redirect: to.fullPath } };
}
});
export default router;
4) Pinia Auth Store (JWT + Refresh)
src/stores/auth.ts:
import { defineStore } from "pinia";
import axios from "axios";
const API = import.meta.env.VITE_API_URL || "http://127.0.0.1:8000";
export const useAuthStore = defineStore("auth", {
state: () => ({
access: localStorage.getItem("access") || "",
refresh: localStorage.getItem("refresh") || "",
user: null as null | { id: number; email: string },
}),
getters: {
isAuthenticated: (state) => !!state.access,
},
actions: {
async login(email: string, password: string) {
const { data } = await axios.post(`${API}/api/auth/jwt/create/`, { email, password });
this.access = data.access;
this.refresh = data.refresh;
localStorage.setItem("access", this.access);
localStorage.setItem("refresh", this.refresh);
await this.fetchUser();
},
async refreshToken() {
if (!this.refresh) return;
const { data } = await axios.post(`${API}/api/auth/jwt/refresh/`, { refresh: this.refresh });
this.access = data.access;
localStorage.setItem("access", this.access);
},
async fetchUser() {
if (!this.access) return;
const { data } = await axios.get(`${API}/api/auth/users/me/`, {
headers: { Authorization: `Bearer ${this.access}` },
});
this.user = data;
},
async logout() {
try {
if (this.refresh) {
await axios.post(`${API}/api/auth/jwt/logout/`, { refresh: this.refresh }, {
headers: { Authorization: `Bearer ${this.access}` },
});
}
} catch (_) {}
this.access = "";
this.refresh = "";
this.user = null;
localStorage.removeItem("access");
localStorage.removeItem("refresh");
},
},
});
Security note: For SPAs, storing JWTs in memory is safer than localStorage; however, many indie projects accept localStorage with short token lifetimes and refresh rotation. For maximum security, consider a backend-for-frontend (BFF) with HTTP-only cookies.
5) Axios Interceptor (Attach Token & Refresh)
src/lib/http.ts:
import axios from "axios";
import { useAuthStore } from "../stores/auth";
const API = import.meta.env.VITE_API_URL || "http://127.0.0.1:8000";
const http = axios.create({
baseURL: API,
});
http.interceptors.request.use((config) => {
const auth = useAuthStore();
if (auth.access) {
config.headers = config.headers || {};
(config.headers as any).Authorization = `Bearer ${auth.access}`;
}
return config;
});
let isRefreshing = false;
let pending: Array<() => void> = [];
http.interceptors.response.use(
(res) => res,
async (error) => {
const auth = useAuthStore();
const original = error.config;
if (error.response?.status === 401 && !original._retry && auth.refresh) {
if (isRefreshing) {
await new Promise<void>((resolve) => pending.push(resolve));
original.headers.Authorization = `Bearer ${auth.access}`;
return http(original);
}
original._retry = true;
isRefreshing = true;
try {
await auth.refreshToken();
pending.forEach((r) => r());
pending = [];
original.headers.Authorization = `Bearer ${auth.access}`;
return http(original);
} catch (e) {
auth.logout();
throw e;
} finally {
isRefreshing = false;
}
}
throw error;
}
);
export default http;
Use http everywhere instead of raw axios.
6) Pages: Login, Dashboard, Home
src/pages/Login.vue:
<script setup lang="ts">
import { ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const email = ref("");
const password = ref("");
const loading = ref(false);
const error = ref("");
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
async function submit() {
loading.value = true;
error.value = "";
try {
await auth.login(email.value, password.value);
const redirect = (route.query.redirect as string) || "/dashboard";
router.push(redirect);
} catch (e: any) {
error.value = "Invalid credentials.";
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="max-w-md mx-auto p-6">
<h1 class="text-2xl font-bold mb-4">Login</h1>
<form @submit.prevent="submit" class="space-y-4">
<input v-model="email" type="email" placeholder="Email" class="w-full border p-2 rounded" />
<input v-model="password" type="password" placeholder="Password" class="w-full border p-2 rounded" />
<button :disabled="loading" class="w-full py-2 rounded bg-black text-white">
{{ loading ? "Logging in..." : "Login" }}
</button>
<p v-if="error" class="text-red-600 text-sm">{{ error }}</p>
</form>
</div>
</template>
src/pages/Dashboard.vue (fetch protected endpoint):
<script setup lang="ts">
import { onMounted, ref } from "vue";
import http from "../lib/http";
const quotes = ref<any[]>([]);
const error = ref("");
onMounted(async () => {
try {
const { data } = await http.get("/api/quotes/");
quotes.value = data.results ?? data; // handle pagination or plain list
} catch (e) {
error.value = "Failed to load quotes.";
}
});
</script>
<template>
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-4">Dashboard</h1>
<p v-if="error" class="text-red-600">{{ error }}</p>
<ul class="space-y-3">
<li v-for="q in quotes" :key="q.id" class="border rounded p-3">
<p class="font-medium">“{{ q.text }}”</p>
<p class="text-sm text-gray-500">— {{ q.author }}</p>
</li>
</ul>
</div>
</template>
src/pages/Home.vue:
<script setup lang="ts"></script>
<template>
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-3xl font-bold mb-2">Home</h1>
<p>Welcome. Use the nav to login and view your dashboard.</p>
</div>
</template>
Add a simple nav in App.vue:
<script setup lang="ts">
import { useAuthStore } from "./stores/auth";
const auth = useAuthStore();
</script>
<template>
<nav class="p-4 border-b flex gap-4">
<router-link to="/">Home</router-link>
<router-link to="/dashboard">Dashboard</router-link>
<router-link v-if="!auth.isAuthenticated" to="/login">Login</router-link>
<button v-else @click="auth.logout()">Logout</button>
</nav>
<router-view />
</template>
7) Environment Variables (Vite)
Create .env in vue-client/:
VITE_API_URL=http://127.0.0.1:8000
Use import.meta.env.VITE_API_URL (already done in store and http).
8) Docker Compose (Optional Dev)
Top-level docker-compose.yml (example):
version: "3.9"
services:
api:
build: ./server
env_file:
- ./server/.env
ports:
- "8000:8000"
web:
build: ./vue-client
working_dir: /app
command: ["npm","run","dev","--","--host","0.0.0.0","--port","5173"]
volumes:
- ./vue-client:/app
environment:
- VITE_API_URL=http://api:8000
ports:
- "5173:5173"
depends_on:
- api
Django CORS must allow http://web:5173 or just use CORS_ALLOW_ALL_ORIGINS = True for local Docker dev (not for prod).
9) Production Notes (Fly.io + Netlify)
- Django API → Fly.io (Docker).
- Set
CORS_ALLOWED_ORIGINSto your production frontend URL, e.g.https://yourdomain.com.
- Set
- Vue SPA → Netlify, Vercel, or Fly Machines.
- Build with
VITE_API_URLpointing to your API (e.g.,https://api.yourdomain.com).
- Build with
- JWT Lifetimes → Keep short access tokens (e.g., 15–60 min) and rotate refresh tokens.
✅ Quick Checklist
- DRF + Djoser + SimpleJWT configured
- CORS headers added for dev & prod
- Vue router guards for protected routes
- Pinia store handling login, refresh, logout (blacklist)
- Axios interceptors attach token & auto-refresh
- Environment variables for API URL
- Optional Docker compose for local full-stack dev
🏁 Conclusion
You now have a clean, modern full-stack setup:
- Django REST API with JWT auth
- Vue 3 SPA with protected routes and automatic token refresh
- Configs that scale from local dev → Docker → Fly.io/Netlify
This pattern is production-grade and reusable for SaaS, dashboards, and mobile backends.
Next up: django-ci-cd-pipeline-with-github-actions.md — automate your build & deploys.
Written by Bailey Burnsed — Senior Software Engineer, Founder of BaileyBurnsed.dev