Skip to content

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.

cd db
sqitch add projects -n "Add 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:

./sqitch.sh dev deploy

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:

cd frontend
npm run generate

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

  1. Start the application:

    docker-compose up -d
    

  2. Navigate to projects: Visit http://localhost:5173/projects

  3. Test CRUD operations:

  4. Create a new project
  5. View the projects list
  6. Edit existing projects
  7. 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:

  1. Start with the database schema
  2. Build from the data layer up (repository → service → routes)
  3. Configure dependency injection
  4. Generate and use the API client on the frontend
  5. 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