FastAPI + Vue 3: How to Build a Modern REST API with Python and Consume It from the Frontend
Complete end-to-end tutorial: build a REST API with FastAPI and Python, then consume it from Vue 3 with Composition API and TypeScript. JWT authentication, CORS, Docker deployment included.

FastAPI + Vue 3: How to Build a Modern REST API with Python and Consume It from the Frontend
If you're looking for the ideal stack to build a modern web application with Python on the backend and a reactive frontend, FastAPI + Vue 3 is one of the most solid and productive combinations available today. FastAPI is the Python framework that has grown the most in the last three years, and Vue 3 with Composition API has definitively changed how we write reactive interfaces.
In this tutorial we build together a real end-to-end application: a task management API with JWT authentication, and a Vue 3 frontend that consumes it. By the end you'll have a functional stack, with best practices, ready to scale.
What you'll learn:
📝 Update — May 2026: After feedback from the Reddit community, two issues in the original version were fixed: (1) inconsistent use of literal status codes vs
status.HTTP_*, and (2) manual commits in routers instead of a centralized context manager inget_db(). Both fixes are applied in the current code. Thanks to those who pointed out the errors.
Reference Repositories:
- Official FastAPI creator template (with React, but the backend architecture is identical): github.com/fastapi/full-stack-fastapi-template
- FastAPI + Vue3 + Naive UI (2.1k ⭐ — very similar stack to this article): github.com/topics/fastapi?l=vue
- Vue3-FastAPI-WebApp-template (includes Pinia + Axios + WebSockets): github.com/Tomansion/Vue3-FastAPI-WebApp-template
Tech Stack
| Layer | Technology | Version |
|---|---|---|
| Backend | Python + FastAPI | 3.12 / 0.111+ |
| ORM | SQLAlchemy + Alembic | 2.x |
| Validation | Pydantic v2 | 2.x |
| Authentication | python-jose + passlib | JWT |
| Database | PostgreSQL | 16 |
| Frontend | Vue 3 + TypeScript | 3.4+ |
| State management | Pinia | 2.x |
| HTTP client | Axios | 1.x |
| Build tool | Vite | 5.x |
| Containers | Docker + Compose | 26+ |
Part 1: The Backend with FastAPI
Project structure
Before writing a single line of code, structure matters. A well-organized FastAPI project looks like this:
backend/
├── app/
│ ├── __init__.py
│ ├── main.py # Entry point
│ ├── config.py # Settings con Pydantic
│ ├── database.py # Conexión a PostgreSQL
│ ├── models/ # Modelos SQLAlchemy
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── task.py
│ ├── schemas/ # Schemas Pydantic (request/response)
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── task.py
│ ├── routers/ # Endpoints organizados por dominio
│ │ ├── __init__.py
│ │ ├── auth.py
│ │ └── tasks.py
│ ├── services/ # Lógica de negocio
│ │ ├── auth.py
│ │ └── tasks.py
│ └── dependencies.py # Dependencias reutilizables
├── alembic/ # Migraciones de base de datos
├── tests/
├── .env
├── requirements.txt
└── Dockerfile
This layer separation —models, schemas, routers, services— keeps the code maintainable as the project grows. Routers know about HTTP; services know about business logic; models know about the database. Each layer has a single responsibility.
Installing dependencies
# Crear entorno virtual
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Instalar dependencias
pip install fastapi uvicorn[standard] sqlalchemy alembic \
psycopg2-binary pydantic-settings python-jose[cryptography] \
passlib[bcrypt] python-multipart
requirements.txt:
fastapi==0.111.0
uvicorn[standard]==0.29.0
sqlalchemy==2.0.30
alembic==1.13.1
psycopg2-binary==2.9.9
pydantic-settings==2.2.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
Configuration with Pydantic Settings
An essential practice: centralize configuration in a single place, reading from environment variables.
# app/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Base
APP_NAME: str = "FastAPI + Vue3 App"
DEBUG: bool = False
API_V1_PREFIX: str = "/api/v1"
# Base de datos
DATABASE_URL: str
# JWT
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# CORS — lista de orígenes permitidos
ALLOWED_ORIGINS: list[str] = ["http://localhost:5173"]
class Config:
env_file = ".env"
@lru_cache()
def get_settings() -> Settings:
return Settings()
settings = get_settings()
The @lru_cache() decorator ensures the Settings object is created only once during the application's lifecycle — not on every request.
.env:
DATABASE_URL=postgresql://postgres:secret@localhost:5432/fastapi_vue3
SECRET_KEY=tu-clave-secreta-muy-larga-y-aleatoria-aqui
DEBUG=True
ALLOWED_ORIGINS=["http://localhost:5173","http://localhost:3000"]
Database connection
# app/database.py
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import settings
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True, # Verifica conexión antes de usarla
pool_size=10, # Conexiones en el pool
max_overflow=20,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""
Dependency que provee una sesión de base de datos por request.
Hace commit automático si el bloque termina sin excepciones;
rollback automático si algo falla. Los routers nunca tocan
commit() ni rollback() directamente.
"""
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
Why a context manager instead of manual commits?
When the router calls
db.commit()manually, any exception that occurs after a write but before the commit leaves the database in an inconsistent state without a rollback. The context manager inget_db()centralizes this responsibility: if the request finishes successfully, it commits; if it throws any exception, it automatically rolls back. Routers stay clean — they only work with objects, never with transaction management.
SQLAlchemy Models
# app/models/user.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
tasks = relationship("Task", back_populates="owner", cascade="all, delete-orphan")
# app/models/task.py
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime, Enum
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.database import Base
import enum
class TaskStatus(str, enum.Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(String, nullable=True)
status = Column(Enum(TaskStatus), default=TaskStatus.PENDING)
is_completed = Column(Boolean, default=False)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
owner = relationship("User", back_populates="tasks")
Pydantic v2 Schemas
Schemas are the contract between the client and the API. They define what is received and what is returned — separated from the database models.
# app/schemas/user.py
from pydantic import BaseModel, EmailStr, Field
from datetime import datetime
class UserCreate(BaseModel):
email: EmailStr
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8)
class UserResponse(BaseModel):
id: int
email: EmailStr
username: str
is_active: bool
created_at: datetime
model_config = {"from_attributes": True} # Equivalente a orm_mode en v1
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: int | None = None
# app/schemas/task.py
from pydantic import BaseModel, Field
from datetime import datetime
from app.models.task import TaskStatus
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str | None = None
status: TaskStatus = TaskStatus.PENDING
class TaskUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
status: TaskStatus | None = None
is_completed: bool | None = None
class TaskResponse(BaseModel):
id: int
title: str
description: str | None
status: TaskStatus
is_completed: bool
owner_id: int
created_at: datetime
updated_at: datetime | None
model_config = {"from_attributes": True}
JWT Authentication Service
# app/services/auth.py
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.config import settings
from app.models.user import User
from app.schemas.user import UserCreate, TokenData
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> TokenData:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id: int = payload.get("sub")
if user_id is None:
raise ValueError("Token inválido")
return TokenData(user_id=int(user_id))
except JWTError:
raise ValueError("Token inválido o expirado")
def get_user_by_email(db: Session, email: str) -> User | None:
return db.query(User).filter(User.email == email).first()
def create_user(db: Session, user_data: UserCreate) -> User:
db_user = User(
email=user_data.email,
username=user_data.username,
hashed_password=hash_password(user_data.password),
)
db.add(db_user)
db.flush() # Persiste en la transacción activa; el commit lo gestiona get_db()
db.refresh(db_user)
return db_user
Reusable Dependencies
# app/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.auth import decode_token
from app.models.user import User
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
try:
token_data = decode_token(token)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token inválido o expirado",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).filter(User.id == token_data.user_id).first()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Usuario no encontrado",
)
return user
Routers
# app/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas.user import UserCreate, UserResponse, Token
from app.services.auth import (
get_user_by_email, create_user, verify_password,
create_access_token, create_refresh_token
)
router = APIRouter(prefix="/auth", tags=["Autenticación"])
@router.post("/register", response_model=UserResponse, status_code=201)
def register(user_data: UserCreate, db: Session = Depends(get_db)):
if get_user_by_email(db, user_data.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El email ya está registrado",
)
return create_user(db, user_data)
@router.post("/login", response_model=Token)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = get_user_by_email(db, form_data.username)
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales incorrectas",
)
return Token(
access_token=create_access_token({"sub": str(user.id)}),
refresh_token=create_refresh_token({"sub": str(user.id)}),
)
# app/routers/tasks.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.models.task import Task
from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse
router = APIRouter(prefix="/tasks", tags=["Tareas"])
@router.get("/", response_model=list[TaskResponse])
def get_tasks(
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return (
db.query(Task)
.filter(Task.owner_id == current_user.id)
.offset(skip)
.limit(limit)
.all()
)
@router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
def create_task(
task_data: TaskCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
task = Task(**task_data.model_dump(), owner_id=current_user.id)
db.add(task)
db.flush() # Persiste en la transacción sin commit — get_db() hace el commit al cerrar
db.refresh(task)
return task
@router.put("/{task_id}", response_model=TaskResponse)
def update_task(
task_id: int,
task_data: TaskUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
task = db.query(Task).filter(Task.id == task_id, Task.owner_id == current_user.id).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tarea no encontrada",
)
for field, value in task_data.model_dump(exclude_unset=True).items():
setattr(task, field, value)
db.flush()
db.refresh(task)
return task
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
task = db.query(Task).filter(Task.id == task_id, Task.owner_id == current_user.id).first()
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tarea no encontrada",
)
db.delete(task)
Entry point — main.py
# app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, tasks
from app.database import Base, engine
# Crear tablas (en producción usa Alembic)
Base.metadata.create_all(bind=engine)
app = FastAPI(
title=settings.APP_NAME,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
# CORS — crítico para que Vue pueda comunicarse
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Registrar routers
app.include_router(auth.router, prefix=settings.API_V1_PREFIX)
app.include_router(tasks.router, prefix=settings.API_V1_PREFIX)
@app.get("/health")
def health_check():
return {"status": "ok", "app": settings.APP_NAME}
With this the backend is complete. Start it with:
uvicorn app.main:app --reload --port 8000
The interactive documentation will be at http://localhost:8000/api/docs — one of the most productive advantages of FastAPI.
Part 2: The Frontend with Vue 3
Create the project with Vite
npm create vue@latest frontend
# Seleccionar:
# ✅ TypeScript
# ✅ Vue Router
# ✅ Pinia
# ✅ ESLint + Prettier
cd frontend
npm install
npm install axios
Frontend structure
frontend/
├── src/
│ ├── api/ # Capa de comunicación con la API
│ │ ├── client.ts # Instancia Axios configurada
│ │ ├── auth.ts
│ │ └── tasks.ts
│ ├── stores/ # Estado global con Pinia
│ │ ├── auth.store.ts
│ │ └── tasks.store.ts
│ ├── composables/ # Lógica reutilizable
│ │ └── useNotification.ts
│ ├── views/ # Páginas
│ │ ├── LoginView.vue
│ │ ├── RegisterView.vue
│ │ └── TasksView.vue
│ ├── components/
│ │ ├── TaskCard.vue
│ │ └── TaskForm.vue
│ ├── router/
│ │ └── index.ts
│ ├── types/ # Interfaces TypeScript
│ │ └── index.ts
│ └── App.vue
TypeScript Types
// src/types/index.ts
export interface User {
id: number
email: string
username: string
is_active: boolean
created_at: string
}
export interface Token {
access_token: string
refresh_token: string
token_type: string
}
export type TaskStatus = 'pending' | 'in_progress' | 'completed'
export interface Task {
id: number
title: string
description: string | null
status: TaskStatus
is_completed: boolean
owner_id: number
created_at: string
updated_at: string | null
}
export interface TaskCreate {
title: string
description?: string
status?: TaskStatus
}
export interface TaskUpdate {
title?: string
description?: string
status?: TaskStatus
is_completed?: boolean
}
Axios Client with Interceptors
This is the most important piece of the frontend — an Axios client that attaches the token automatically on every request and handles renewal when it expires.
// src/api/client.ts
import axios from 'axios'
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1',
headers: {
'Content-Type': 'application/json',
},
})
// Interceptor de request — adjunta el token automáticamente
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Interceptor de response — maneja token expirado (401)
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const refreshToken = localStorage.getItem('refresh_token')
const response = await axios.post(
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
{ refresh_token: refreshToken }
)
const { access_token } = response.data
localStorage.setItem('access_token', access_token)
originalRequest.headers.Authorization = `Bearer ${access_token}`
return apiClient(originalRequest)
} catch {
// Refresh falló — limpiar sesión
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export default apiClient
API Layer
// src/api/auth.ts
import apiClient from './client'
import type { Token, User } from '@/types'
export const authApi = {
async register(email: string, username: string, password: string): Promise<User> {
const { data } = await apiClient.post<User>('/auth/register', {
email,
username,
password,
})
return data
},
async login(email: string, password: string): Promise<Token> {
// FastAPI espera form-data para OAuth2
const formData = new URLSearchParams()
formData.append('username', email)
formData.append('password', password)
const { data } = await apiClient.post<Token>('/auth/login', formData, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
return data
},
}
// src/api/tasks.ts
import apiClient from './client'
import type { Task, TaskCreate, TaskUpdate } from '@/types'
export const tasksApi = {
async getAll(skip = 0, limit = 20): Promise<Task[]> {
const { data } = await apiClient.get<Task[]>('/tasks', {
params: { skip, limit },
})
return data
},
async create(task: TaskCreate): Promise<Task> {
const { data } = await apiClient.post<Task>('/tasks', task)
return data
},
async update(id: number, task: TaskUpdate): Promise<Task> {
const { data } = await apiClient.put<Task>(`/tasks/${id}`, task)
return data
},
async remove(id: number): Promise<void> {
await apiClient.delete(`/tasks/${id}`)
},
}
Auth Store with Pinia
// src/stores/auth.store.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/auth'
import type { User } from '@/types'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => {
return !!localStorage.getItem('access_token')
})
async function login(email: string, password: string) {
isLoading.value = true
error.value = null
try {
const tokens = await authApi.login(email, password)
localStorage.setItem('access_token', tokens.access_token)
localStorage.setItem('refresh_token', tokens.refresh_token)
} catch (err: any) {
error.value = err.response?.data?.detail || 'Error al iniciar sesión'
throw err
} finally {
isLoading.value = false
}
}
async function register(email: string, username: string, password: string) {
isLoading.value = true
error.value = null
try {
user.value = await authApi.register(email, username, password)
} catch (err: any) {
error.value = err.response?.data?.detail || 'Error al registrarse'
throw err
} finally {
isLoading.value = false
}
}
function logout() {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
user.value = null
}
return { user, isLoading, error, isAuthenticated, login, register, logout }
})
Tasks Store
// src/stores/tasks.store.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { tasksApi } from '@/api/tasks'
import type { Task, TaskCreate, TaskUpdate } from '@/types'
export const useTasksStore = defineStore('tasks', () => {
const tasks = ref<Task[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
async function fetchTasks() {
isLoading.value = true
try {
tasks.value = await tasksApi.getAll()
} catch (err: any) {
error.value = 'No se pudieron cargar las tareas'
} finally {
isLoading.value = false
}
}
async function addTask(data: TaskCreate) {
const task = await tasksApi.create(data)
tasks.value.unshift(task)
return task
}
async function updateTask(id: number, data: TaskUpdate) {
const updated = await tasksApi.update(id, data)
const index = tasks.value.findIndex((t) => t.id === id)
if (index !== -1) tasks.value[index] = updated
return updated
}
async function removeTask(id: number) {
await tasksApi.remove(id)
tasks.value = tasks.value.filter((t) => t.id !== id)
}
return { tasks, isLoading, error, fetchTasks, addTask, updateTask, removeTask }
})
Main Tasks View
<!-- src/views/TasksView.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useTasksStore } from '@/stores/tasks.store'
import { useAuthStore } from '@/stores/auth.store'
import { useRouter } from 'vue-router'
import TaskCard from '@/components/TaskCard.vue'
import TaskForm from '@/components/TaskForm.vue'
import type { TaskCreate } from '@/types'
const tasksStore = useTasksStore()
const authStore = useAuthStore()
const router = useRouter()
const showForm = ref(false)
onMounted(() => tasksStore.fetchTasks())
async function handleCreate(data: TaskCreate) {
await tasksStore.addTask(data)
showForm.value = false
}
function handleLogout() {
authStore.logout()
router.push('/login')
}
</script>
<template>
<div class="tasks-container">
<header class="tasks-header">
<h1>My Tasks</h1>
<div class="header-actions">
<button class="btn-primary" @click="showForm = !showForm">
{{ showForm ? 'Cancel' : '+ New Task' }}
</button>
<button class="btn-ghost" @click="handleLogout">Log out</button>
</div>
</header>
<TaskForm v-if="showForm" @submit="handleCreate" />
<div v-if="tasksStore.isLoading" class="loading">Loading tasks...</div>
<div v-else-if="tasksStore.tasks.length === 0" class="empty-state">
<p>You don't have any tasks yet. Create the first one!</p>
</div>
<div v-else class="tasks-grid">
<TaskCard
v-for="task in tasksStore.tasks"
:key="task.id"
:task="task"
@update="tasksStore.updateTask(task.id, $event)"
@delete="tasksStore.removeTask(task.id)"
/>
</div>
</div>
</template>
Router with Navigation Guards
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { requiresGuest: true },
},
{
path: '/register',
name: 'register',
component: () => import('@/views/RegisterView.vue'),
meta: { requiresGuest: true },
},
{
path: '/tasks',
name: 'tasks',
component: () => import('@/views/TasksView.vue'),
meta: { requiresAuth: true },
},
{
path: '/',
redirect: '/tasks',
},
],
})
// Guarda de navegación global
router.beforeEach((to) => {
const isAuthenticated = !!localStorage.getItem('access_token')
if (to.meta.requiresAuth && !isAuthenticated) {
return { name: 'login' }
}
if (to.meta.requiresGuest && isAuthenticated) {
return { name: 'tasks' }
}
})
export default router
Part 3: Docker Compose — Putting It All Together
# docker-compose.yml
version: '3.8'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
POSTGRES_DB: fastapi_vue3
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
backend:
build: ./backend
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://postgres:secret@db:5432/fastapi_vue3
SECRET_KEY: cambia-esta-clave-en-produccion
DEBUG: "false"
ALLOWED_ORIGINS: '["http://localhost:5173"]'
depends_on:
db:
condition: service_healthy
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
frontend:
build: ./frontend
ports:
- "5173:80"
environment:
VITE_API_BASE_URL: http://localhost:8000/api/v1
depends_on:
- backend
volumes:
postgres_data:
# backend/Dockerfile
FROM python:3.12-slim
WORKDIR /app
# Instalar dependencias del sistema
RUN apt-get update && apt-get install -y \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# frontend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
Launch everything with a single command:
docker-compose up --build
Best Practices and Final Considerations
On the backend:
passlib with bcrypt is the standardcreate_all()slowapistatus.HTTP_ instead of literal numbers** — status.HTTP_404_NOT_FOUND is readable, searchable, and refactorable; 404 is a magic numberget_db() — routers should never call db.commit() directly. Use db.flush() when you need the ID before the transaction closesOn the frontend:
localStorage is sufficient for many cases, but consider httpOnly cookies for greater security in critical applicationsdefineStore with Pinia's setup syntax — it's more flexible and TypeScript-friendlyFor production:
SECRET_KEY to a value generated with openssl rand -hex 32Conclusion
FastAPI and Vue 3 form a stack that balances productivity and performance in a way that is hard to beat with Python. FastAPI gives you automatic validation, generated documentation, and performance close to Node.js. Vue 3 with Composition API and Pinia gives you granular reactivity and highly maintainable code.
The pattern we saw here —layer separation, contracts with TypeScript, interceptors for auth, Pinia stores— scales seamlessly from a personal project to a SaaS application with thousands of users.
If you want to see this same pattern applied to a security and vulnerability detection context, you can check out DevGuardian AI, where I use this exact stack in production.
Additional Resources
Do you have questions about this stack or are you building something similar? You can find me on GitHub or write to me from my portfolio.