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:

  • Crear una API REST completa con FastAPI, Pydantic v2 y SQLAlchemy
  • Implementar autenticación JWT con refresh tokens
  • Configurar CORS correctamente para que Vue pueda consumir la API
  • Construir el frontend con Vue 3, Composition API, TypeScript y Pinia
  • Manejar autenticación y rutas protegidas en el cliente
  • Dockerizar ambos servicios con docker-compose
  • 📝 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 en get_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

    CapaTecnologíaVersión
    BackendPython + FastAPI3.12 / 0.111+
    ORMSQLAlchemy + Alembic2.x
    ValidaciónPydantic v22.x
    Autenticaciónpython-jose + passlibJWT
    Base de datosPostgreSQL16
    FrontendVue 3 + TypeScript3.4+
    Estado globalPinia2.x
    HTTP clientAxios1.x
    Build toolVite5.x
    ContenedoresDocker + Compose26+

    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 en get_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:

  • Nunca guardes contraseñas en texto plano — passlib con bcrypt es el estándar
  • Valida siempre que el recurso pertenece al usuario autenticado antes de modificarlo
  • Usa Alembic para migraciones en lugar de create_all() directo
  • Configura rate limiting en producción con slowapi
  • *Usa siempre status.HTTP_ en lugar de números literales** — status.HTTP_404_NOT_FOUND es legible, buscable y refactorizable; 404 es un número mágico
  • Centraliza commit/rollback en get_db() — los routers nunca deben llamar db.commit() directamente. Usa db.flush() cuando necesites el ID antes del cierre de la transacción
  • En el frontend:

  • Los tokens en localStorage son suficientes para muchos casos, pero considera httpOnly cookies para mayor seguridad en aplicaciones críticas
  • Centraliza el manejo de errores en los interceptores de Axios — no dupliques lógica de error en cada composable
  • Usa defineStore con el setup syntax de Pinia — es más flexible y TypeScript-friendly
  • Para producción:

  • Cambia SECRET_KEY por un valor generado con openssl rand -hex 32
  • Configura HTTPS — sin TLS, JWT en tránsito no tiene valor
  • Considera Redis para blacklisting de tokens al hacer logout

  • Conclusió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

  • FastAPI docs: fastapi.tiangolo.com
  • Vue 3 docs: vuejs.org
  • Pinia docs: pinia.vuejs.org
  • Pydantic v2: docs.pydantic.dev
  • SQLAlchemy 2.0: docs.sqlalchemy.org

  • ¿Tienes preguntas sobre este stack o estás construyendo algo similar? Puedes encontrarme en GitHub o escribirme desde mi portfolio.