FastAPI + Vue 3: Cómo Construir una API REST Moderna con Python y Consumirla desde el Frontend
Tutorial completo end-to-end: construye una API REST con FastAPI y Python, luego consúmela desde Vue 3 con Composition API y TypeScript. Autenticación JWT, CORS, despliegue con Docker incluido.

FastAPI + Vue 3: Cómo Construir una API REST Moderna con Python y Consumirla desde el Frontend
Si buscas el stack ideal para construir una aplicación web moderna con Python en el backend y un frontend reactivo, FastAPI + Vue 3 es una de las combinaciones más sólidas y productivas disponibles hoy. FastAPI es el framework Python que más ha crecido en los últimos tres años, y Vue 3 con Composition API cambió definitivamente cómo escribimos interfaces reactivas.
En este tutorial construimos juntos una aplicación real de extremo a extremo: una API de gestión de tareas con autenticación JWT, y un frontend en Vue 3 que la consume. Al final tendrás un stack funcional, con buenas prácticas, listo para escalar.
Lo que vas a aprender:
📝 Actualización — Mayo 2026: Tras feedback de la comunidad en Reddit, se corrigieron dos problemas en la versión original: (1) uso inconsistente de status codes literales vs
status.HTTP_*, y (2) commits manuales en routers en lugar de un context manager centralizado enget_db(). Ambas correcciones están aplicadas en el código actual. Gracias a los que señalaron los errores.
Repositorios de referencia:
- Template oficial del creador de FastAPI (con React, pero la arquitectura backend es idéntica): github.com/fastapi/full-stack-fastapi-template
- FastAPI + Vue3 + Naive UI (2.1k ⭐ — stack muy similar al de este artículo): github.com/topics/fastapi?l=vue
- Vue3-FastAPI-WebApp-template (incluye Pinia + Axios + WebSockets): github.com/Tomansion/Vue3-FastAPI-WebApp-template
Stack tecnológico
| Capa | Tecnología | Versión |
|---|---|---|
| Backend | Python + FastAPI | 3.12 / 0.111+ |
| ORM | SQLAlchemy + Alembic | 2.x |
| Validación | Pydantic v2 | 2.x |
| Autenticación | python-jose + passlib | JWT |
| Base de datos | PostgreSQL | 16 |
| Frontend | Vue 3 + TypeScript | 3.4+ |
| Estado global | Pinia | 2.x |
| HTTP client | Axios | 1.x |
| Build tool | Vite | 5.x |
| Contenedores | Docker + Compose | 26+ |
Parte 1: El Backend con FastAPI
Estructura del proyecto
Antes de escribir una línea de código, la estructura importa. Un proyecto FastAPI bien organizado se parece a esto:
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
Esta separación en capas —modelos, schemas, routers, services— mantiene el código mantenible cuando el proyecto crece. Los routers saben de HTTP; los services saben de lógica de negocio; los modelos saben de base de datos. Cada capa tiene una sola responsabilidad.
Instalación de dependencias
# 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
Configuración con Pydantic Settings
Una práctica esencial: centralizar la configuración en un solo lugar, leyendo desde variables de entorno.
# 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()
El decorador @lru_cache() garantiza que el objeto Settings se crea una sola vez durante el ciclo de vida de la aplicación — no en cada 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"]
Conexión a la base de datos
# 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()
¿Por qué un context manager y no commits manuales?
Cuando el router llama
db.commit()manualmente, cualquier excepción que ocurra después de una escritura pero antes del commit deja la base de datos en estado inconsistente sin hacer rollback. El context manager enget_db()centraliza esta responsabilidad: si el request termina bien, hace commit; si lanza cualquier excepción, hace rollback automáticamente. Los routers quedan limpios — solo trabajan con objetos, nunca con la gestión de la transacción.
Modelos SQLAlchemy
# 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")
Schemas Pydantic v2
Los schemas son el contrato entre el cliente y la API. Definen qué se recibe y qué se devuelve — separados de los modelos de base de datos.
# 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}
Servicio de autenticación JWT
# 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
Dependencias reutilizables
# 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}
Con esto el backend está completo. Levántalo con:
uvicorn app.main:app --reload --port 8000
La documentación interactiva estará en http://localhost:8000/api/docs — una de las ventajas más productivas de FastAPI.
Parte 2: El Frontend con Vue 3
Crear el proyecto con Vite
npm create vue@latest frontend
# Seleccionar:
# ✅ TypeScript
# ✅ Vue Router
# ✅ Pinia
# ✅ ESLint + Prettier
cd frontend
npm install
npm install axios
Estructura del frontend
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
Tipos TypeScript
// 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
}
Cliente Axios con interceptores
Esta es la pieza más importante del frontend — un cliente Axios que adjunta el token automáticamente en cada request y maneja la renovación cuando expira.
// 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
Capa de API
// 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}`)
},
}
Store de autenticación con 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 }
})
Store de tareas
// 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 }
})
Vista principal de tareas
<!-- 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>Mis Tareas</h1>
<div class="header-actions">
<button class="btn-primary" @click="showForm = !showForm">
{{ showForm ? 'Cancelar' : '+ Nueva tarea' }}
</button>
<button class="btn-ghost" @click="handleLogout">Cerrar sesión</button>
</div>
</header>
<TaskForm v-if="showForm" @submit="handleCreate" />
<div v-if="tasksStore.isLoading" class="loading">Cargando tareas...</div>
<div v-else-if="tasksStore.tasks.length === 0" class="empty-state">
<p>No tienes tareas aún. ¡Crea la primera!</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 con guardas de navegación
// 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
Parte 3: Docker Compose — Unir todo
# 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
Levantar todo con un comando:
docker-compose up --build
Buenas prácticas y consideraciones finales
En el backend:
passlib con bcrypt es el estándarcreate_all() directoslowapistatus.HTTP_ en lugar de números literales** — status.HTTP_404_NOT_FOUND es legible, buscable y refactorizable; 404 es un número mágicoget_db() — los routers nunca deben llamar db.commit() directamente. Usa db.flush() cuando necesites el ID antes del cierre de la transacciónEn el frontend:
localStorage son suficientes para muchos casos, pero considera httpOnly cookies para mayor seguridad en aplicaciones críticasdefineStore con el setup syntax de Pinia — es más flexible y TypeScript-friendlyPara producción:
SECRET_KEY por un valor generado con openssl rand -hex 32Conclusión
FastAPI y Vue 3 forman un stack que equilibra productividad y rendimiento de forma difícil de superar con Python. FastAPI te da validación automática, documentación generada y performance cercana a Node.js. Vue 3 con Composition API y Pinia te da reactividad granular y código altamente mantenible.
El patrón que vimos aquí —separación en capas, contratos con TypeScript, interceptores para auth, stores de Pinia— escala sin problemas desde un proyecto personal hasta una aplicación SaaS con miles de usuarios.
Si quieres ver este mismo patrón aplicado a un contexto de seguridad y detección de vulnerabilidades, puedes revisar DevGuardian AI, donde uso exactamente este stack en producción.
Recursos adicionales
¿Tienes preguntas sobre este stack o estás construyendo algo similar? Puedes encontrarme en GitHub o escribirme desde mi portfolio.