Tutorials
This section provides step-by-step guides for extending FastSvelte with new functionality.
Adding a New Entity (End-to-End)
This tutorial walks through adding a complete "Projects" feature to demonstrate how to extend every layer of the FastSvelte stack. You'll learn to add database tables, backend APIs, and frontend interfaces.
Overview
We'll build a Projects feature that allows users to: - Create, read, update, and delete projects - Associate projects with the current organization - Display projects in a list and detail view
Step 1: Database Schema
First, create a new migration for the projects table.
Edit the generated migration file db/deploy/projects.sql:
-- Deploy {schema_name}:projects to pg
-- Replace {schema_name} with your actual schema name (set during init.py)
BEGIN;
CREATE TABLE IF NOT EXISTS {schema_name}.project (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
organization_id INTEGER NOT NULL REFERENCES {schema_name}.organization(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES {schema_name}."user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_project_organization ON {schema_name}.project(organization_id);
CREATE INDEX IF NOT EXISTS idx_project_user ON {schema_name}.project(user_id);
COMMIT;
Create the revert script db/revert/projects.sql:
-- Revert {schema_name}:projects from pg
BEGIN;
DROP INDEX IF EXISTS {schema_name}.idx_project_user;
DROP INDEX IF EXISTS {schema_name}.idx_project_organization;
DROP TABLE IF EXISTS {schema_name}.project;
COMMIT;
Apply the migration:
Step 2: Backend Models
Create the Pydantic models in backend/app/model/project.py:
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class ProjectEntity(BaseModel):
id: int
name: str
description: str | None = None
organization_id: int
user_id: int
created_at: datetime
updated_at: datetime
class CreateProjectRequest(BaseModel):
name: str
description: str | None = None
class UpdateProjectRequest(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
id: int
name: str
description: str | None = None
created_at: datetime
updated_at: datetime
Step 3: Repository Layer
Create backend/app/data/repo/project_repo.py:
from app.data.repo.base_repo import BaseRepo
from app.model.project import ProjectEntity
class ProjectRepo(BaseRepo):
async def create_project(
self,
name: str,
description: str | None,
user_id: int,
organization_id: int
) -> ProjectEntity:
query = f"""
INSERT INTO {self.schema}.project (name, description, user_id, organization_id)
VALUES ($1, $2, $3, $4)
RETURNING id, name, description, organization_id, user_id, created_at, updated_at
"""
row = await self.fetch_one(query, name, description, user_id, organization_id)
return ProjectEntity(**row)
async def get_project_by_id(self, project_id: int, organization_id: int) -> ProjectEntity | None:
query = f"""
SELECT id, name, description, organization_id, user_id, created_at, updated_at
FROM {self.schema}.project
WHERE id = $1 AND organization_id = $2
"""
row = await self.fetch_one(query, project_id, organization_id)
return ProjectEntity(**row) if row else None
async def get_projects_by_organization(self, organization_id: int) -> list[ProjectEntity]:
query = f"""
SELECT id, name, description, organization_id, user_id, created_at, updated_at
FROM {self.schema}.project
WHERE organization_id = $1
ORDER BY created_at DESC
"""
rows = await self.fetch_all(query, organization_id)
return [ProjectEntity(**row) for row in rows]
async def update_project(
self,
project_id: int,
name: str | None,
description: str | None,
organization_id: int
) -> ProjectEntity | None:
query = f"""
UPDATE {self.schema}.project
SET name = COALESCE($3, name),
description = COALESCE($4, description),
updated_at = now()
WHERE id = $1 AND organization_id = $2
RETURNING id, name, description, organization_id, user_id, created_at, updated_at
"""
row = await self.fetch_one(query, project_id, organization_id, name, description)
return ProjectEntity(**row) if row else None
async def delete_project(self, project_id: int, organization_id: int) -> None:
query = f"DELETE FROM {self.schema}.project WHERE id = $1 AND organization_id = $2"
await self.execute(query, project_id, organization_id)
Step 4: Service Layer
Create backend/app/service/project_service.py:
from app.data.repo.project_repo import ProjectRepo
from app.model.project import CreateProjectRequest, ProjectEntity, UpdateProjectRequest
class ProjectService:
def __init__(self, project_repo: ProjectRepo):
self.project_repo = project_repo
async def create_project(
self,
organization_id: int,
user_id: int,
data: CreateProjectRequest
) -> ProjectEntity:
return await self.project_repo.create_project(
data.name, data.description, user_id, organization_id
)
async def list_projects(self, organization_id: int) -> list[ProjectEntity]:
return await self.project_repo.get_projects_by_organization(organization_id)
async def get_project(
self,
project_id: int,
organization_id: int
) -> ProjectEntity | None:
return await self.project_repo.get_project_by_id(project_id, organization_id)
async def update_project(
self,
project_id: int,
organization_id: int,
data: UpdateProjectRequest
) -> ProjectEntity | None:
return await self.project_repo.update_project(
project_id, data.name, data.description, organization_id
)
async def delete_project(self, project_id: int, organization_id: int) -> None:
await self.project_repo.delete_project(project_id, organization_id)
Step 5: API Routes
Create backend/app/api/route/project_route.py:
from app.api.middleware.auth_handler import min_role_required
from app.config.container import Container
from app.exception.common_exception import ResourceNotFound
from app.model.project import CreateProjectRequest, ProjectResponse, UpdateProjectRequest
from app.model.role_model import Role
from app.model.user_model import CurrentUser
from app.service.project_service import ProjectService
from dependency_injector.wiring import Provide, inject
from fastapi import APIRouter, Depends
router = APIRouter()
@router.post("", response_model=ProjectResponse, operation_id="createProject")
@inject
async def create_project(
data: CreateProjectRequest,
user: CurrentUser = Depends(min_role_required(Role.MEMBER)),
project_service: ProjectService = Depends(Provide[Container.project_service]),
):
project = await project_service.create_project(
user.organization_id, user.id, data
)
return ProjectResponse.model_validate(project.model_dump())
@router.get("", response_model=list[ProjectResponse], operation_id="listProjects")
@inject
async def list_projects(
user: CurrentUser = Depends(min_role_required(Role.MEMBER)),
project_service: ProjectService = Depends(Provide[Container.project_service]),
):
projects = await project_service.list_projects(user.organization_id)
return [ProjectResponse.model_validate(p.model_dump()) for p in projects]
@router.get("/{project_id}", response_model=ProjectResponse, operation_id="getProject")
@inject
async def get_project(
project_id: int,
user: CurrentUser = Depends(min_role_required(Role.MEMBER)),
project_service: ProjectService = Depends(Provide[Container.project_service]),
):
project = await project_service.get_project(project_id, user.organization_id)
if not project:
raise ResourceNotFound("project", project_id)
return ProjectResponse.model_validate(project.model_dump())
@router.put("/{project_id}", response_model=ProjectResponse, operation_id="updateProject")
@inject
async def update_project(
project_id: int,
data: UpdateProjectRequest,
user: CurrentUser = Depends(min_role_required(Role.MEMBER)),
project_service: ProjectService = Depends(Provide[Container.project_service]),
):
project = await project_service.update_project(
project_id, user.organization_id, data
)
if not project:
raise ResourceNotFound("project", project_id)
return ProjectResponse.model_validate(project.model_dump())
@router.delete("/{project_id}", status_code=204, operation_id="deleteProject")
@inject
async def delete_project(
project_id: int,
user: CurrentUser = Depends(min_role_required(Role.MEMBER)),
project_service: ProjectService = Depends(Provide[Container.project_service]),
):
project = await project_service.get_project(project_id, user.organization_id)
if not project:
raise ResourceNotFound("project", project_id)
await project_service.delete_project(project_id, user.organization_id)
Step 6: Dependency Injection Setup
Update backend/app/config/container.py to include the new components:
# 1. Add to imports at the top of the file
from app.data.repo.project_repo import ProjectRepo
from app.service.project_service import ProjectService
# 2. Add to the Container class in the repositories section (around line 47)
project_repo = providers.Singleton(ProjectRepo, db_config=db_config)
# 3. Add to the Container class in the services section (around line 157)
project_service = providers.Factory(
ProjectService,
project_repo=project_repo,
)
Step 7: Register Routes
Update backend/app/api/router.py to include the new routes:
# 1. Add to imports at the top
from app.api.route.project_route import router as project_router
# 2. Add to include_all_routers() function (around line 31)
app.include_router(project_router, prefix="/projects", tags=["Projects"])
Step 8: Generate Frontend API Client
After adding the backend routes, regenerate the TypeScript API client:
This creates the API functions in src/lib/api/gen/ that you'll use in the frontend.
Step 9: Frontend Components
Create the projects list page at frontend/src/routes/(protected)/projects/+page.svelte:
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { listProjects, deleteProject } from '$lib/api/gen/projects';
import type { ProjectResponse } from '$lib/api/gen/model';
let projects = $state<ProjectResponse[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(loadProjects);
async function loadProjects() {
loading = true;
try {
const response = await listProjects();
projects = response.data || [];
} catch (err) {
error = 'Failed to load projects';
console.error('Error loading projects:', err);
} finally {
loading = false;
}
}
async function handleDelete(projectId: number) {
if (!confirm('Are you sure you want to delete this project?')) return;
try {
await deleteProject(projectId);
await loadProjects();
} catch (err) {
error = 'Failed to delete project';
console.error('Error deleting project:', err);
}
}
</script>
<div class="container mx-auto p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Projects</h1>
<button class="btn btn-primary" on:click={() => goto('/projects/new')}>
Create Project
</button>
</div>
{#if loading}
<div class="flex justify-center">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if error}
<div class="alert alert-error">
<span>{error}</span>
</div>
{:else if projects.length === 0}
<div class="text-center py-8">
<p class="text-gray-500 mb-4">No projects yet</p>
<button class="btn btn-primary" on:click={() => goto('/projects/new')}>
Create Your First Project
</button>
</div>
{:else}
<div class="grid gap-4">
{#each projects as project}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">{project.name}</h2>
{#if project.description}
<p>{project.description}</p>
{/if}
<p class="text-sm text-gray-500">
Created: {new Date(project.created_at).toLocaleDateString()}
</p>
<div class="card-actions justify-end">
<button
class="btn btn-outline btn-sm"
on:click={() => goto(`/projects/${project.id}`)}
>
View
</button>
<button
class="btn btn-outline btn-sm"
on:click={() => goto(`/projects/${project.id}/edit`)}
>
Edit
</button>
<button
class="btn btn-error btn-outline btn-sm"
on:click={() => handleDelete(project.id)}
>
Delete
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
Create the new project form at frontend/src/routes/(protected)/projects/new/+page.svelte:
<script lang="ts">
import { goto } from '$app/navigation';
import { createProject } from '$lib/api/gen/projects';
import type { CreateProjectRequest } from '$lib/api/gen/model';
let form = $state<CreateProjectRequest>({
name: '',
description: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
async function handleSubmit() {
if (!form.name.trim()) {
error = 'Project name is required';
return;
}
loading = true;
error = null;
try {
await createProject(form);
goto('/projects');
} catch (err) {
error = 'Failed to create project';
console.error('Error creating project:', err);
} finally {
loading = false;
}
}
</script>
<div class="container mx-auto p-6 max-w-md">
<h1 class="text-3xl font-bold mb-6">Create New Project</h1>
{#if error}
<div class="alert alert-error mb-4">
<span>{error}</span>
</div>
{/if}
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div class="form-control">
<label class="label" for="name">
<span class="label-text">Project Name *</span>
</label>
<input
id="name"
type="text"
class="input input-bordered"
bind:value={form.name}
required
disabled={loading}
/>
</div>
<div class="form-control">
<label class="label" for="description">
<span class="label-text">Description</span>
</label>
<textarea
id="description"
class="textarea textarea-bordered"
rows="3"
bind:value={form.description}
disabled={loading}
></textarea>
</div>
<div class="flex gap-2">
<button
type="submit"
class="btn btn-primary flex-1"
class:loading
disabled={loading}
>
{loading ? 'Creating...' : 'Create Project'}
</button>
<button
type="button"
class="btn btn-outline"
on:click={() => goto('/projects')}
disabled={loading}
>
Cancel
</button>
</div>
</form>
</div>
Step 10: Add Navigation
Update your main navigation to include the projects link. In your sidebar component, add:
<li>
<a href="/projects" class="flex items-center gap-2">
<span class="iconify lucide--folder"></span>
Projects
</a>
</li>
Step 11: Test the Feature
-
Start the application:
-
Navigate to projects: Visit
http://localhost:5173/projects -
Test CRUD operations:
- Create a new project
- View the projects list
- Edit existing projects
- Delete projects
Summary
You've successfully added a complete "Projects" feature that demonstrates:
- Database design: Migration with proper foreign keys and constraints
- Backend architecture: Repository pattern, service layer, API routes
- Dependency injection: Proper container configuration
- API design: RESTful endpoints with proper HTTP status codes
- Frontend integration: API client generation and UI components
- Security: Organization-based data isolation and authentication
This pattern can be applied to add any new entity to your FastSvelte application. The key principles are:
- Start with the database schema
- Build from the data layer up (repository → service → routes)
- Configure dependency injection
- Generate and use the API client on the frontend
- Build UI components following the existing design patterns
Next Steps
Consider extending this tutorial with: - Adding relationships between entities - Implementing search and filtering - Adding file uploads to entities - Creating custom business logic in services - Building reusable form components