Originally published at turtledev.io
In my previous post, I talked about why I moved from SvelteKit SSR to a Svelte SPA + FastAPI architecture. Today, I want to show you my setup with a simple project.
We'll build a simple todo list app to demonstrate how the frontend and backend communicate, and how to write less and type-safe code by using Orval to auto-generate TypeScript API clients from FastAPI's OpenAPI specs.
Building a production app? Check out FastSvelte - a production-ready FastAPI + SvelteKit starter with authentication, payments, and more built-in.
Project Structure
todo-app/
├── backend/ # FastAPI Python backend
│ ├── main.py # FastAPI app
│ ├── models.py # Pydantic models
│ └── requirements.txt
│
└── frontend/ # SvelteKit SPA
├── src/
│ ├── routes/ # Pages
│ └── lib/ # API client & components
└── package.json
Backend: FastAPI Setup
Create a backend directory and set up a virtual environment:
cd backend
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
requirements.in
We'll use pip-compile from pip-tools to manage dependencies:
- Simple dependency specification: List only your direct dependencies without worrying about version pins
-
Clear dependency tree: The generated
requirements.txtshows direct vs transitive dependencies - Reproducible builds: All versions are pinned for consistent installations
-
Easy updates: Run
pip-compileagain to update to latest compatible versions
fastapi
uvicorn[standard]
pydantic
Install pip-tools and compile dependencies
pip install pip-tools
pip-compile requirements.in
pip install -r requirements.txt
models.py
# backend/models.py
from pydantic import BaseModel
class TodoCreate(BaseModel):
title: str
completed: bool = False
class TodoUpdate(BaseModel):
title: str | None = None
completed: bool | None = None
class Todo(BaseModel):
id: int
title: str
completed: bool
main.py
# backend/main.py
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from models import Todo, TodoCreate, TodoUpdate
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"], # Vite dev server
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
todos: dict[int, Todo] = {}
next_id = 1
@app.get("/todos", response_model=list[Todo], operation_id="listTodos")
def list_todos():
"""Get all todos"""
return list(todos.values())
@app.post("/todos", response_model=Todo, operation_id="createTodo")
def create_todo(todo_data: TodoCreate):
"""Create a new todo"""
global next_id
todo = Todo(id=next_id, title=todo_data.title, completed=todo_data.completed)
todos[next_id] = todo
next_id += 1
return todo
@app.get("/todos/{todo_id}", response_model=Todo, operation_id="getTodo")
def get_todo(todo_id: int):
"""Get a specific todo"""
if todo_id not in todos:
raise HTTPException(status_code=404, detail="Todo not found")
return todos[todo_id]
@app.put("/todos/{todo_id}", response_model=Todo, operation_id="updateTodo")
def update_todo(todo_id: int, todo_data: TodoUpdate):
"""Update a todo"""
if todo_id not in todos:
raise HTTPException(status_code=404, detail="Todo not found")
todo = todos[todo_id]
if todo_data.title is not None:
todo.title = todo_data.title
if todo_data.completed is not None:
todo.completed = todo_data.completed
return todo
@app.delete("/todos/{todo_id}", status_code=204, operation_id="deleteTodo")
def delete_todo(todo_id: int):
"""Delete a todo"""
if todo_id not in todos:
raise HTTPException(status_code=404, detail="Todo not found")
del todos[todo_id]
Notice the operation_id on each route. This tells FastAPI to use clean names like listTodos in the OpenAPI spec instead of auto-generated ones like list_todos_todos_get. Orval will use these as TypeScript function names.
Start the backend
uvicorn main:app --reload
API running at http://localhost:8000. Docs at http://localhost:8000/docs.
Test the API
# Create a todo
curl -X POST http://localhost:8000/todos \
-H "Content-Type: application/json" \
-d '{"title": "Learn FastAPI", "completed": false}'
# List all todos
curl http://localhost:8000/todos
# Update a todo
curl -X PUT http://localhost:8000/todos/1 \
-H "Content-Type: application/json" \
-d '{"completed": true}'
# Delete a todo
curl -X DELETE http://localhost:8000/todos/1
The OpenAPI spec is at http://localhost:8000/openapi.json — this is what Orval will use to generate the TypeScript client.
Frontend: SvelteKit SPA
npx sv create frontend
Select: SvelteKit minimal, TypeScript, prettier, npm.
Configure as SPA
Create src/routes/+layout.ts:
export const csr = true; // Enable client-side rendering
export const ssr = false; // Disable server-side rendering
export const prerender = false; // Disable prerendering
Install Dependencies
npm install axios
npm install -D orval
Setup Auto-Generated API Client
Create orval.config.cjs:
module.exports = {
default: {
input: {
target: 'http://localhost:8000/openapi.json'
},
output: {
target: './src/lib/api/gen',
schemas: './src/lib/api/gen/model',
client: 'axios',
mode: 'split',
clean: true,
baseUrl: 'http://localhost:8000'
}
}
};
Add a generate script to package.json:
{
"scripts": {
"generate": "npx orval --config orval.config.cjs"
}
}
Generate TypeScript Client
With your backend running:
npm run generate
This creates src/lib/api/gen/ with fully typed functions for all your endpoints.
Build the UI
Create src/routes/+page.svelte:
<script lang="ts">
import { onMount } from 'svelte';
import { getFastAPI } from '$lib/api/gen/fastAPI';
import type { Todo } from '$lib/api/gen/model';
const api = getFastAPI();
let todos = $state<Todo[]>([]);
let newTodoTitle = $state('');
let loading = $state(false);
async function loadTodos() {
loading = true;
try {
const response = await api.listTodos();
todos = response.data;
} catch (error) {
console.error('Failed to load todos:', error);
} finally {
loading = false;
}
}
async function addTodo() {
if (!newTodoTitle.trim()) return;
try {
const response = await api.createTodo({ title: newTodoTitle, completed: false });
todos = [...todos, response.data];
newTodoTitle = '';
} catch (error) {
console.error('Failed to create todo:', error);
}
}
async function toggleTodo(todo: Todo) {
try {
const response = await api.updateTodo(todo.id, { completed: !todo.completed });
todos = todos.map((t) => t.id === todo.id ? response.data : t);
} catch (error) {
console.error('Failed to update todo:', error);
}
}
async function removeTodo(id: number) {
try {
await api.deleteTodo(id);
todos = todos.filter((t) => t.id !== id);
} catch (error) {
console.error('Failed to delete todo:', error);
}
}
onMount(() => { loadTodos(); });
</script>
<div class="container">
<h1>Todo List</h1>
<div class="add-todo">
<input
type="text"
bind:value={newTodoTitle}
placeholder="What needs to be done?"
onkeydown={(e) => e.key === 'Enter' && addTodo()}
/>
<button onclick={addTodo}>Add</button>
</div>
{#if loading}
<p>Loading...</p>
{:else if todos.length === 0}
<p class="empty">No todos yet. Add one above!</p>
{:else}
<ul class="todo-list">
{#each todos as todo (todo.id)}
<li class:completed={todo.completed}>
<input type="checkbox" checked={todo.completed} onchange={() => toggleTodo(todo)} />
<span>{todo.title}</span>
<button class="delete" onclick={() => removeTodo(todo.id)}>×</button>
</li>
{/each}
</ul>
{/if}
</div>
Start the frontend
npm run dev
App running at http://localhost:5173.
Updating the API
When you add a field to a model:
class TodoCreate(BaseModel):
title: str
completed: bool = False
priority: str = "medium" # New field!
Just regenerate:
npm run generate
TypeScript will immediately show errors wherever you need to update the frontend. No manual type syncing.
What We Built
- FastAPI backend with CRUD endpoints
- SvelteKit SPA frontend
- Auto-generated TypeScript API client from OpenAPI spec
- Fully functional todo app with end-to-end type safety
Source code: GitHub
Next: How to Add Authentication to a SvelteKit SPA
See also: Full-stack FastAPI Tutorial 1: Project Setup & Tooling
If you want a production-ready version with authentication, multi-tenancy, and Stripe already wired up, check out FastSvelte.
Smooth coding!
Top comments (0)