Skip to content

Quick Containerized Web Apps with Vue.js, FastAPI, and OpenAPIΒΆ

Prerequisites

Recently, I needed to make a quick app to call Google Cloud functions, pause and clear task queues, and monitor storage. I already had a bunch of library code written in Python, so a FastAPI backend tied to a Vue.js frontend made sense. In hindsight, something like HTMX might have been simpler, but it's too late now πŸ˜….

When you make a FastAPI server, the endpoints and models are automatically described in an OpenAPI specification, which a code generator can use to create a JavaScript client. This way, we can just import this generated code into Vue instead of manually rewriting any models or fetch requests in JavaScript.

We'll go through setting all of this up step-by-step, but feel free to go straight to the finished code on GitHub.

Initial Project SetupΒΆ

In this section, we'll create a boilerplate Vue app and FastAPI server. The only goal is to have them run on localhost, not to communicate with each other.

We'll be making a directory structure like this:

vuefast/
β”œβ”€β”€ backend/
β”‚   └── # FastAPI code
└── frontend/
    └── # Vue code

I'm naming the root of my project vuefast, which you could replace with your own project name. When following this tutorial, commands will reference this name, so be sure to update them with your project's name.

Vue Frontend ScaffoldingΒΆ

We'll use the Vue CLI to create and run a new Vue project. This section is essentially the Vue quickstart with more details.

Navigate to the directory where you want both the front and backend to reside. I'll make a new directory called vuefast:

~$ mkdir vuefast
~$ cd vuefast

With npm we can create a new Vue+Vite project in this directory. The following command and options will make a new frontend/ folder with a default Vue app:

npm create vue@latest
βœ” Project name: frontend
βœ” Add TypeScript? … No / Yes
βœ” Add JSX Support? … No / Yes
βœ” Add Vue Router for Single Page Application development? … No / Yes
βœ” Add Pinia for state management? … No / Yes
βœ” Add Vitest for Unit testing? … No / Yes
βœ” Add an End-to-End Testing Solution? … No / Cypress / Nightwatch / Playwright
βœ” Add ESLint for code quality? … No / Yes
βœ” Add Prettier for code formatting? … No / Yes
βœ” Add Vue DevTools 7 extension for debugging? (experimental) … No / Yes

Scaffolding project in ./frontend
Done.

Feel free to choose any options you like here, but make sure to add Typescript support. We'll need it later, and it's a real hassle to add to an existing project. Plus, you can still write regular .js files with Typescript enabled.

To finish the Vue setup, the CLI will prompt you to run the following commands:

cd frontend
npm install
npm run dev
>frontend@0.0.0 dev
>vite --host

  VITE v5.3.3  ready in 1799 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: http://172.25.32.1:5173/
  ➜  Network: http://172.31.48.1:5173/
  ➜  Network: http://192.168.1.68:5173/
  ➜  Network: http://192.168.56.1:5173/
  ➜  Network: http://192.168.99.1:5173/
  ➜  Vue DevTools: Open http://localhost:5173/__devtools__/ as a separate window
  ➜  Vue DevTools: Press Alt(βŒ₯)+Shift(⇧)+D in App to toggle the Vue DevTools

  ➜  press h + enter to show help
Project Layout (frontend)
vuefast/
└──frontend/
    β”œβ”€β”€ public/
    β”œβ”€β”€ node_modules/
    β”œβ”€β”€ src/
    β”‚   β”œβ”€β”€ assets/
    β”‚   β”œβ”€β”€ components/
    β”‚   β”‚   β”œβ”€β”€ icons/
    β”‚   β”‚   β”œβ”€β”€ HelloWorld.vue
    β”‚   β”‚   β”œβ”€β”€ TheWelcome.vue
    β”‚   β”‚   └── WelcomeItem.vue
    β”‚   β”œβ”€β”€ App.vue
    β”‚   └── main.ts
    β”œβ”€β”€ .gitignore
    β”œβ”€β”€ env.d.ts
    β”œβ”€β”€ index.html
    β”œβ”€β”€ package-lock.json
    β”œβ”€β”€ package.json
    β”œβ”€β”€ README.md
    β”œβ”€β”€ tsconfig.app.json
    β”œβ”€β”€ tsconfig.json
    β”œβ”€β”€ tsconfig.node.json
    └── vite.config.ts

You should be able to visit localhost:5173 in your browser to see the scaffolded Vue app.

Scaffolded Vue app on localhost

It has many default components and styles we'll need to delete later, but we do have a fully functioning Vue app on localhost. Let's set up a basic FastAPI server, then containerizing both with Docker.

FastAPI Server ScaffoldingΒΆ

Let's add a backend folder, into which we'll add a main.py to hold our server code and a requirements.txt to store the required libs for the project.

mkdir backend
cd backend
touch main.py requirements.txt
Project Layout (backend)
vuefast/
β”œβ”€β”€ backend/
β”‚   β”œβ”€β”€ main.py
β”‚   └── requirements.txt
└── frontend/
    └── ...

Here's a minimal FastAPI server to get things working:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}
fastapi
uvicorn

Make sure to pip install the requirements:

pip install -r requirements.txt

Then use uvicorn to run the FastAPI server to see that it's working:

uvicorn main:app --host 0.0.0.0 --port 8000 --reload

You should be able to go to localhost:8000 and see {"message": "Hello World"}.

We have both services running on regular dev servers, so let's containerize the app with Docker.

ContainerizingΒΆ

Putting both services into Docker containers will make it easier to run and deploy. We'll use Docker Compose to coordinate how the front and backend containers work together, so if you don't already have Docker installed, get it here.

Create the following files from the project's root:

touch docker-compose.yml frontend/Dockerfile frontend/.dockerignore backend/Dockerfile
Project Layout (docker)
vuefast/
β”œβ”€β”€ docker-compose.yml
β”œβ”€β”€ backend/
β”‚   β”œβ”€β”€ Dockerfile
β”‚   └── ...
└── frontend/
    β”œβ”€β”€ Dockerfile
    β”œβ”€β”€ .dockerignore
    └── ...

The front and backend both get their own Dockerfiles, which Compose will use to build the images required to run the apps.

DockerfilesΒΆ

The Dockerfiles for both parts are fairly straightforward: copy files, install dependencies, and run a dev server.

For the frontend, we need a .dockerignore to ensure node_modules isn't copied into the container. We do this to make building and updating faster, and to provide a clean environment for the container.

FROM node:22-slim

WORKDIR /frontend

COPY package*.json . 
RUN npm ci
#(1)!
COPY . .

CMD ["npm", "run", "dev"]
  1. Copy package.json and package-lock.json separately and run a clean install
node_modules
FROM python:3.12-alpine

WORKDIR /backend

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

If you're wondering why we copy package.json for the frontend and requirements.txt for the backend separately, install them, and then* copy all the code, it's because of Docker layer caching. Each command forms a new layer, so if the app's code changes but not the installed packages, then Docker can avoid reinstalling everything on update.

Docker ComposeΒΆ

For the docker-compose.yml, we create two services: a frontend service that runs Vue and a backend service that runs FastAPI. Check the code annotations for more details about specific lines.

docker-compose.yml
services:
  backend:
    build: ./backend
    ports:
      - "8000:8000" # (1)!
    volumes:
      - ./backend:/backend # (2)!

  frontend:
    build: ./frontend
    ports:
      - "4200:4200" # (3)!
    volumes:
      - ./frontend:/frontend # (4)!
      - /frontend/node_modules # (5)!
    environment:
      - CHOKIDAR_USEPOLLING=true # (6)!
  1. Map your 8000 port to the container's 8000 port.
  2. Tell Docker to watch for changes in your ./backend directory and automatically make them available in the container.
  3. Map your 4200 port to the container's 4200 port.
  4. Tell Docker to watch for changes in your ./frontend directory and automatically make them available in the container.
  5. Use a "named volume" to prevent overwriting node_modules in the containerβ€”makes updates and building faster.
  6. Required for hot-reloading changes in the frontend

The backend service will run as is, but we need to modify the frontend's Vite run command to let us connect to container's host from outside.

In package.json add --host to the dev run command:

package.json
{
  "name": "frontend",
  "scripts": {
    "dev": "vite --host",
    ...
  },
  ...
}

Finally, we can build and launch the containers with Docker Compose using the following command:

docker compose up --build
...
frontend-1  |
frontend-1  | > frontend@0.0.0 dev
frontend-1  | > vite --host
frontend-1  |
frontend-1  |
frontend-1  |   VITE v5.3.3  ready in 745 ms
frontend-1  |
frontend-1  |   ➜  Local:   http://localhost:5173/
frontend-1  |   ➜  Network: http://172.19.0.2:5173/
frontend-1  |   ➜  Vue DevTools: Open http://localhost:5173/__devtools__/ as a separate window
frontend-1  |   ➜  Vue DevTools: Press Alt(βŒ₯)+Shift(⇧)+D in App to toggle the Vue DevTools
frontend-1  |
backend-1   | INFO:     Started server process [1]
backend-1   | INFO:     Waiting for application startup.
backend-1   | INFO:     Application startup complete.
backend-1   | INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

You should see a bunch of build logs that settle to showing both containers running on their respective ports.

Going to localhost:5173 in your browser should show Vue frontend running again, and on localhost:8000 you'll see the backend. The difference now is both are inside Docker containers. To stop the containers, use CTRL-C.

Error connecting to Docker

On Windows, if you get an error along the lines of error during connect: Get "http://... make sure Docker Desktop is running.

Additionally, both services are hot-reloading, so editing your code locally will automatically be reflected in the containers.

We'll now work on the communication between the two apps by implementing an example app.

Writing a simple appΒΆ

For the sake of simplicity, we'll make an app that can create and list todos. This should let you see how everything works together, and allow you to easily translate the functionality to your own project.

FastAPI: configuring the serverΒΆ

We need to add a few lines with how the server is configured. First is to add arguments when creating app:

/backend/main.py
app = FastAPI(
        title="vuefast-backend",
        servers=[{"url": "http://localhost:8000"}] # (1)!
)

# ...
  1. Necessary for OpenAPI to generate the required basepath

The title is just a preference, but the servers argument is necessary when generating JS code later. It establishes which host and port the FastAPI server is running on so the JS fetch requests are routed properly.

Next, even though we're running on localhost, we still need a CORS policy to allow our Vue frontend to make requests to our backend.

/backend/main.py
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
        CORSMiddleware,
        allow_origins=["http://localhost:5173"], # (1)!
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
)

# ...
  1. The host:port of the frontend

We set up Docker Compose to run our frontend on localhost:5173, so this policy allows all requests coming from the frontend.

Now we're set up to create some models and endpoints, so let's start by listing some Todos.

FastAPI: Listing TodosΒΆ

For the first feature of our app, we'll make a simple way for Vue to retrieve todos from FastAPI.

Data modelsΒΆ

Using Pydantic to model our todos, FastAPI will automatically handle validation and request/response conversion to and from JSON. A Todo just needs to inherit from BaseModel:

/backend/main.py
from enum import StrEnum
from pydantic import BaseModel

class Priority(StrEnum):
    LOW = 'low'
    MEDIUM = 'medium'
    HIGH = 'high'

class Todo(BaseModel):
    id: str
    title: str
    created: int
    priority: Priority | None = None
    completed: bool = False

I've added a priority, which is not a Pydantic model, to show later how the JS generator makes the conversion. This could be any other data in your backend, business logic, or third-party library, and shows how you can save time from having to manually duplicate your Python models in JS.

DatabaseΒΆ

Our server needs a way to store todos. Let's just use a simple in-memory dictionary:

/backend/main.py
# ...

todos: dict[str, Todo] = {} # (1)!

todos.update({ # (2)!
    '1': Todo(id='1', title='this is a todo from FastAPI', created=1, priority=Priority.LOW, completed=False),
    '2': Todo(id='2', title='this is another one πŸ˜„', created=2, priority=Priority.MEDIUM, completed=False)
})
  1. In-memory database for demo. id -> Todo
  2. Adding some temporary todos for listing

Later, when Vue creates a new todo, our server will store it in todos. Restarting the server will erase any new todos in the dictionary, but this is fine for prototyping. I've also added a couple of temporary todos so Vue has something to retrieve.

List todos endpointΒΆ

The following is an endpoint for Vue to call to retrieve all todos in the database:

/backend/main.py
# ...

@app.get('/todos')
async def list_todos() -> list[Todo]:
    return [t for t in todos.values() if not t.completed]

We're only listing the todos that haven't been completed yet. This is so when Vue completes a todo, it won't reappear on page reload.

Interestingly, the return type list[Todo] is a normal Python static type hint, but it allows FastAPI to create the correct OpenAPI spec for the JS generator.

Generating JSΒΆ

Normally, at this point, you'd consider manually programming the same Python data models in JS to receive the Todo JSON in Vue.

Instead, FastAPI created an OpenAPI spec on localhost:8000/openapi.json, which contains everything a code generator needs to make the models and endpoints for us.

localhost:8000/openapi.json
// 20240806134752
// http://localhost:8000/openapi.json

{
  "openapi": "3.1.0",
  "info": {
    "title": "vuefast-backend",
    "version": "0.1.0"
  },
  "servers": [
    {
      "url": "http://localhost:8000"
    }
  ],
  "paths": {
    "/todos": {
      "get": {
        "summary": "List Todos",
        "operationId": "list_todos",
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "items": {
                    "$ref": "#/components/schemas/Todo"
                  },
                  "type": "array",
                  "title": "Response List Todos Todos Get"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Priority": {
        "type": "string",
        "enum": [
          "low",
          "medium",
          "high"
        ],
        "title": "Priority"
      },
      "Todo": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id"
          },
          "title": {
            "type": "string",
            "title": "Title"
          },
          "created": {
            "type": "integer",
            "title": "Created"
          },
          "priority": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Priority"
              },
              {
                "type": "null"
              }
            ]
          },
          "completed": {
            "type": "boolean",
            "title": "Completed",
            "default": false
          }
        },
        "type": "object",
        "required": [
          "id",
          "title",
          "created"
        ],
        "title": "Todo"
      }
    }
  }
}

It might be a little confusing to look at, but if you follow the keys, you'll see that each part of our server is modeled in JSON. Our endpoints are listed under the paths key, and our models are listed under components.

Configure route namingΒΆ

One thing we should change is how the spec defines our routes, which are defined under operationID keys in paths. The JS generator will use these operation IDs to name the requests and they are named redundantly to avoid conflicts.

For example, our list_todos endpoint would be named list_todos_todos_get in JS. To change how the spec generates these IDs, we can add a renaming function to the server:

/backend/main.py
from fastapi.routing import APIRoute

# ... everything else

def setup_openapi(app: FastAPI) -> None:  # (1)! 
    for route in app.routes:
        if isinstance(route, APIRoute):
            route.operation_id = route.name

setup_openapi(app)
  1. Source: https://github.com/tiangolo/fastapi/issues/442#issuecomment-522266678

We need to call this function after all route definitions, so just stick it at the very end of main.py.

With this change, the /todos endpoint is named list_todos, the same name we wrote in Python. Much nicer.

Using the generatorΒΆ

There are few options for generating JS, but we'll use openapi-generator paired with typescript-fetch. The typescript-fetch generator seems to be the most concise and doesn't require any install/build process for additional packages.

The following command uses npx to run the generator. We feed it the spec on localhost:8000/openapi.json and output the JavaScript code into frontend/src/generated-api.

~/vuefast/frontend$ npx @openapitools/openapi-generator-cli generate -i http://localhost:8000/openapi.json -g typescript-fetch -o ./src/generated-api --additional-properties=useEs6=true
Generated API layout
frontend/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ generated-api/
β”‚   β”‚   β”œβ”€β”€ apis/
β”‚   β”‚   β”‚   β”œβ”€β”€ DefaultApi.ts
β”‚   β”‚   β”‚   └── index.ts
β”‚   β”‚   β”œβ”€β”€ models/
β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ Priority.ts
β”‚   β”‚   β”‚   └── Todo.ts
β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   └── runtime.ts
β”‚   β”œβ”€β”€ ...   
β”œβ”€β”€ ...

If you navigate to frontend/src/generated-api and peruse the files, you'll see the models and endpoints we created in FastAPI have been translated into JS. You'll even notice the Priority enum we created has been translated into a JS enum.

Now, all we need to do is instantiate the API found in apis/DefaultAPI.ts, and use the functions that correspond to backend endpoints. We'll see how this works in the next section.

Vue: TodoList ComponentΒΆ

To reduce the clutter, I'm removing some of the scaffolded files while adding a boilerplate TodoList.vue.

 frontend/
 β”œβ”€β”€ src/
 β”‚   β”œβ”€β”€ assets/
-β”‚   β”‚   β”œβ”€β”€ base.css
-β”‚   β”‚   β”œβ”€β”€ logo.svg
 β”‚   β”‚   └── main.css
 β”‚   β”œβ”€β”€ components/
-β”‚   β”‚   β”œβ”€β”€ icons/
-β”‚   β”‚   β”œβ”€β”€ HelloWorld.vue
-β”‚   β”‚   β”œβ”€β”€ WelcomeItem.vue
-β”‚   β”‚   β”œβ”€β”€ TheWelcome.vue
+β”‚   β”‚   β”œβ”€β”€ TodoList.vue
 β”‚   β”œβ”€β”€ App.vue
 β”‚   └── main.ts
 β”œβ”€β”€ ...

I've also deleted all the styles in main.css.

We need to update App.vue to use this new component:

/frontend/src/App.vue
    <template>
        <main>
            <h1>Todos</h1>
            <TodoList/>
        </main>
    </template>

    <script setup lang="ts">
      import TodoList from "@/components/TodoList.vue"
    </script>

    <style scoped></style>

Now in TodoList.vue, we'll just request the list_todos endpoint using the generated API. We accomplish this by importing and instantiating DefaultAPI and calling the listTodos method. For the HTML template, I'm just populating some divs for now.

/frontend/src/TodoList.vue
<template>
    <div>
        <div v-for="todo in todos" :key="todo.created">
            {{ todo.title }}
        </div>
    </div>
</template>

<script setup>
import {DefaultApi} from '@/generated-api';
import {onMounted, ref} from "vue";

const todos = ref([])

const apiClient = new DefaultApi();

onMounted(() => {
    apiClient.listTodo().then(data => {
        todos.value.push(...data)
    })
})
</script>

<style scoped></style>

And the result:

When the app is mounted, we retrieve the Todos stored in the server's memory. This is cool, but making new Todos is cooler.

FastAPI: Creating TodosΒΆ

Let's make a new endpoint Vue can POST some data to:

/backend/main.py
import uuid
from datetime import datetime

# ...

class CreateTodoRequest(BaseModel):
    title: str
    priority: Priority | None = None

@app.post('/todos', status_code=201)
async def create_todo(req: CreateTodoRequest) -> Todo:
    new_id = str(uuid.uuid4())
    created = int(datetime.timestamp(datetime.now()))
    new_todo = Todo(id=new_id, created=created, **req.dict())
    todos[new_id] = new_todo
    return new_todo

# ...

This one's a little different because we're defining a separate request object instead of using a Todo directly.

This is because we don't want the POSTer to fill in anything but title (required) and priority (optional). This lets the server handle creating a new id and created time before returning the new Todo. FastAPI documents this pattern in their extra models section.

To use this new endpoint from Vue, make sure to rerun the OpenAPI generator to update the JavaScript client code before continuing.

Vue: Creating TodosΒΆ

The updated client code now contains a createTodo() API method, which requires title and an optional priority. We'll ignore priority for now and make an input for a title and a button to send the request:

frontend/src/TodoList.vue
<template>
    <!-- ... -->
    <input type="text" v-model="newTodoTitle" placeholder="Add todo...">
    <button @click="newTodo">Add</button>
</template>

<script setup>
// ...

const newTodoTitle = ref('')

function newTodo() {}

// ...
</script>

To figure out how to make the request, we need to explore the generated API. Here are the two relevant areas we need to look at:

@/generated-api

export interface CreateTodoOperationRequest {
    createTodoRequest: CreateTodoRequest;
}

async createTodo(requestParameters: CreateTodoOperationRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Todo> { 
    // ...
}
export interface CreateTodoRequest {
    /**
    *
    * @type {string}
    * @memberof CreateTodoRequest
    */
    title: string;
    /**
    *
    * @type {Priority}
    * @memberof CreateTodoRequest
    */
    priority?: Priority | null;
}

We see that createTodo() takes one required parameter that needs to satisfy the interface CreateTodoOperationRequest. If we go to that definition, we'll see another interface that contains the fields we should fill for the request.

To do this, we make a nested object that satisfies these interfaces:

TodoList.vue
<script setup>
// ...

const newTodoTitle = ref('')

function newTodo() {
    const req = {
        createTodoRequest: {
            title: newTodoTitle.value,
        }
    }

    apiClient.createTodo(req)
        .then(res => {
            todos.value.push(res)
            newTodoTitle.value = ""
        })
        .catch(err => {
            console.error(err)
        })
}

// ...
</script>

The req object satisfies the interfaces and is successfully sent to the server with createTodo.

The createTodo method returns a Promise that resolves to a new Todo, which now includes an id and created field. Once we push the new Todo into the todos array, Vue will instantly update our list to include it.

Here it is in action:

Since adding a priority and completing a Todo follow a similar flow to what we've already covered, I'll leave the rest in the GitHub repo and wrap up the article here.

OnwardΒΆ

On GitHub, you'll find I handled a couple more things, like priority and marking todos complete.

Feel free to read through the full code in the repo and use it as a starting point for your own app.

If you have any questions, suggestions, or fixes, feel free to drop me a line in the comments.