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:

  • Create a complete REST API with FastAPI, Pydantic v2 and SQLAlchemy
  • Implement JWT authentication with refresh tokens
  • Configure CORS correctly so Vue can consume the API
  • Build the frontend with Vue 3, Composition API, TypeScript and Pinia
  • Handle authentication and protected routes on the client
  • Dockerize both services with docker-compose
  • 📝 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 in get_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

    LayerTechnologyVersion
    BackendPython + FastAPI3.12 / 0.111+
    ORMSQLAlchemy + Alembic2.x
    ValidationPydantic v22.x
    Authenticationpython-jose + passlibJWT
    DatabasePostgreSQL16
    FrontendVue 3 + TypeScript3.4+
    State managementPinia2.x
    HTTP clientAxios1.x
    Build toolVite5.x
    ContainersDocker + Compose26+

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

  • Never store passwords in plain text — passlib with bcrypt is the standard
  • Always verify the resource belongs to the authenticated user before modifying it
  • Use Alembic for migrations instead of raw create_all()
  • Configure rate limiting in production with slowapi
  • *Always use status.HTTP_ instead of literal numbers** — status.HTTP_404_NOT_FOUND is readable, searchable, and refactorable; 404 is a magic number
  • Centralize commit/rollback in get_db() — routers should never call db.commit() directly. Use db.flush() when you need the ID before the transaction closes
  • On the frontend:

  • Storing tokens in localStorage is sufficient for many cases, but consider httpOnly cookies for greater security in critical applications
  • Centralize error handling in Axios interceptors — don't duplicate error logic in every composable
  • Use defineStore with Pinia's setup syntax — it's more flexible and TypeScript-friendly
  • For production:

  • Change SECRET_KEY to a value generated with openssl rand -hex 32
  • Set up HTTPS — without TLS, JWT in transit has no value
  • Consider Redis for token blacklisting on logout

  • Conclusion

    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

  • 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

  • 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.