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 fastsvelte:projects to pg
BEGIN;
CREATE TABLE fastsvelte.project (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
organization_id INTEGER NOT NULL REFERENCES fastsvelte.organization(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES fastsvelte."user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
COMMIT;
Create the revert script db/revert/projects.sql
:
-- Revert fastsvelte:projects from pg
BEGIN;
DROP TABLE IF EXISTS fastsvelte.project;
DROP SEQUENCE IF EXISTS fastsvelte.project_id_seq;
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: Optional[str] = None
organization_id: int
user_id: int
created_at: datetime
updated_at: datetime
class CreateProjectRequest(BaseModel):
name: str
description: Optional[str] = None
class UpdateProjectRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class ProjectResponse(BaseModel):
id: int
name: str
description: Optional[str] = None
created_at: datetime
updated_at: datetime
Step 3: Repository Layer
Create backend/app/data/repo/project_repo.py
:
from typing import Optional
from app.data.repo.base_repo import BaseRepo
from app.model.project import ProjectEntity, CreateProjectRequest, UpdateProjectRequest
class ProjectRepo(BaseRepo):
async def create_project(
self,
name: str,
description: Optional[str],
user_id: int,
organization_id: int
) -> ProjectEntity:
query = """
INSERT INTO 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.db.fetchrow(
query, name, description, user_id, organization_id
)
return ProjectEntity(**dict(row))
async def get_project_by_id(self, project_id: int, organization_id: int) -> Optional[ProjectEntity]:
query = """
SELECT id, name, description, organization_id, user_id, created_at, updated_at
FROM project
WHERE id = $1 AND organization_id = $2
"""
row = await self.db.fetchrow(query, project_id, organization_id)
return ProjectEntity(**dict(row)) if row else None
async def get_projects_by_organization(self, organization_id: int) -> list[ProjectEntity]:
query = """
SELECT id, name, description, organization_id, user_id, created_at, updated_at
FROM project
WHERE organization_id = $1
ORDER BY created_at DESC
"""
rows = await self.db.fetch(query, organization_id)
return [ProjectEntity(**dict(row)) for row in rows]
async def update_project(
self,
project_id: int,
name: Optional[str],
description: Optional[str],
organization_id: int
) -> Optional[ProjectEntity]:
# Build dynamic update query
updates = []
params = []
param_count = 1
if name is not None:
updates.append(f"name = ${param_count}")
params.append(name)
param_count += 1
if description is not None:
updates.append(f"description = ${param_count}")
params.append(description)
param_count += 1
if not updates:
return await self.get_project_by_id(project_id, organization_id)
updates.append("updated_at = now()")
params.extend([project_id, organization_id])
query = f"""
UPDATE project
SET {', '.join(updates)}
WHERE id = ${param_count} AND organization_id = ${param_count + 1}
RETURNING id, name, description, organization_id, user_id, created_at, updated_at
"""
row = await self.db.fetchrow(query, *params)
return ProjectEntity(**dict(row)) if row else None
async def delete_project(self, project_id: int, organization_id: int) -> bool:
query = "DELETE FROM project WHERE id = $1 AND organization_id = $2"
result = await self.db.execute(query, project_id, organization_id)
return result.endswith("1") # Check if one row was deleted
Step 4: Service Layer
Create backend/app/service/project_service.py
:
from typing import Optional
from dependency_injector.wiring import inject, Provide
from app.config.container import Container
from app.data.repo.project_repo import ProjectRepo
from app.model.project import ProjectEntity, CreateProjectRequest, UpdateProjectRequest
from app.model.user import UserWithRole
class ProjectService:
@inject
def __init__(
self,
project_repo: ProjectRepo = Provide[Container.project_repo],
):
self.project_repo = project_repo
async def create_project(
self,
request: CreateProjectRequest,
current_user: UserWithRole
) -> ProjectEntity:
return await self.project_repo.create_project(
request.name, request.description, current_user.id, current_user.organization_id
)
async def get_project(
self,
project_id: int,
current_user: UserWithRole
) -> ProjectEntity:
project = await self.project_repo.get_project_by_id(
project_id, current_user.organization_id
)
if not project:
raise ResourceNotFound("project", project_id)
return project
async def list_projects(self, current_user: UserWithRole) -> list[ProjectEntity]:
return await self.project_repo.get_projects_by_organization(
current_user.organization_id
)
async def update_project(
self,
project_id: int,
request: UpdateProjectRequest,
current_user: UserWithRole
) -> ProjectEntity:
# Verify project exists and belongs to organization (throws exception if not found)
await self.get_project(project_id, current_user)
updated_project = await self.project_repo.update_project(
project_id, request.name, request.description, current_user.organization_id
)
if not updated_project:
raise ResourceNotFound("project", project_id)
return updated_project
async def delete_project(
self,
project_id: int,
current_user: UserWithRole
) -> None:
# Verify project exists and belongs to organization (throws exception if not found)
await self.get_project(project_id, current_user)
success = await self.project_repo.delete_project(
project_id, current_user.organization_id
)
if not success:
raise ResourceNotFound("project", project_id)
Step 5: API Routes
Create backend/app/api/route/project_route.py
:
from fastapi import APIRouter, Depends, status
from dependency_injector.wiring import inject, Provide
from app.config.container import Container
from app.exception.common_exception import ResourceNotFound
from app.model.project import CreateProjectRequest, UpdateProjectRequest, ProjectResponse
from app.model.user import UserWithRole
from app.service.project_service import ProjectService
from app.util.auth_util import get_current_user
router = APIRouter(prefix="/projects", tags=["projects"])
@router.post("/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
@inject
async def create_project(
request: CreateProjectRequest,
current_user: UserWithRole = Depends(get_current_user),
project_service: ProjectService = Provide[Container.project_service],
) -> ProjectResponse:
"""Create a new project for the current organization."""
project = await project_service.create_project(request, current_user)
return ProjectResponse(**project.model_dump())
@router.get("/", response_model=list[ProjectResponse])
@inject
async def list_projects(
current_user: UserWithRole = Depends(get_current_user),
project_service: ProjectService = Provide[Container.project_service],
) -> list[ProjectResponse]:
"""List all projects for the current organization."""
projects = await project_service.list_projects(current_user)
return [ProjectResponse(**project.model_dump()) for project in projects]
@router.get("/{project_id}", response_model=ProjectResponse)
@inject
async def get_project(
project_id: int,
current_user: UserWithRole = Depends(get_current_user),
project_service: ProjectService = Provide[Container.project_service],
) -> ProjectResponse:
"""Get a specific project by ID."""
project = await project_service.get_project(project_id, current_user)
return ProjectResponse(**project.model_dump())
@router.put("/{project_id}", response_model=ProjectResponse)
@inject
async def update_project(
project_id: int,
request: UpdateProjectRequest,
current_user: UserWithRole = Depends(get_current_user),
project_service: ProjectService = Provide[Container.project_service],
) -> ProjectResponse:
"""Update a project."""
project = await project_service.update_project(project_id, request, current_user)
return ProjectResponse(**project.model_dump())
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
@inject
async def delete_project(
project_id: int,
current_user: UserWithRole = Depends(get_current_user),
project_service: ProjectService = Provide[Container.project_service],
) -> None:
"""Delete a project."""
await project_service.delete_project(project_id, current_user)
Step 6: Dependency Injection Setup
Update backend/app/config/container.py
to include the new components:
# Add to imports
from app.data.repo.project_repo import ProjectRepo
from app.service.project_service import ProjectService
# Add to the Container class repositories section
project_repo = providers.Factory(ProjectRepo, db=db)
# Add to the Container class services section
project_service = providers.Factory(ProjectService)
Update backend/app/api/router.py
to include the new routes:
# Add to imports
from app.api.route import project_route
# Add to the router includes
app.include_router(project_route.router, prefix="/api")
Step 7: 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 8: 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/projectResponse';
let projects: ProjectResponse[] = [];
let loading = true;
let error: string | null = null;
async function loadProjects() {
try {
loading = true;
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(); // Refresh the list
} catch (err) {
error = 'Failed to delete project';
console.error('Error deleting project:', err);
}
}
onMount(loadProjects);
</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/createProjectRequest';
let form: CreateProjectRequest = {
name: '',
description: ''
};
let loading = false;
let error: string | null = null;
async function handleSubmit() {
if (!form.name.trim()) {
error = 'Project name is required';
return;
}
try {
loading = true;
error = null;
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 9: 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 10: 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