DS DevShelfHub Projects · AI tools
Articles / Learn FastAPI by Building a Photo & Video Sharing App (Auth, DB, Uploads, Streamlit UI)

AI Engineering

Learn FastAPI by Building a Photo & Video Sharing App

By DevShelfHub

One project that covers every FastAPI concept you'll use in real work — routes, Pydantic models, async SQLAlchemy with SQLite, the User and Post data model with foreign keys, multipart file uploads, dependency injection for sessions and current user, JWT auth via FastAPI Users, IDOR-safe update/delete, and a Streamlit test UI. Plus the production gotchas (read-only filesystems, S3 for media, response_model discipline) and the path from SQLite prototype to Postgres deployment.

Learn FastAPI by Building a Photo & Video Sharing App

Introduction

The fastest way to learn FastAPI isn’t reading the docs. It’s building one real project that hits every concept you’ll use later. This walkthrough builds a photo and video sharing app — think early-Instagram minimum — from a single empty file to a working backend with auth, a real database, file uploads, JWT-protected routes, and a Streamlit front-end for testing.

Aimed at developers who know Python but haven’t built a real API yet. By the end you’ll understand the FastAPI mental model: how routes work, what dependency injection actually does, how to model data with Pydantic and SQLAlchemy, how async session management works, and how to plug in authentication without rewriting half the codebase.

📚 Table of contents

  • The big picture: front-end, API, database, auth
  • Project setup with uv
  • Hello world FastAPI and the auto-generated docs
  • First feature: text posts with an in-memory dict
  • Pydantic models for request and response validation
  • Adding SQLAlchemy and async SQLite
  • The data model: User, Post, foreign keys
  • File uploads and media storage
  • Dependency injection: sessions, current user, permissions
  • Auth with FastAPI Users and JWT
  • Updating and deleting posts safely (IDOR defense)
  • Building the Streamlit test UI
  • Common mistakes
  • FAQs

The big picture: front-end, API, database, auth

Every modern web app splits into four layers. Build them in this order:

  1. API (FastAPI) — HTTP routes that accept requests and return JSON.
  2. Database (SQLAlchemy + SQLite/Postgres) — persistent storage with structured types.
  3. Auth (FastAPI Users + JWT) — identify who’s making each request.
  4. Front-end (Streamlit / React / anything) — the UI that calls your API.

For this tutorial: SQLite (file-based, zero setup), JWT-based auth, and Streamlit as the throwaway test UI. Swap any layer later without rewriting the others.

Project setup with uv

Bash
mkdir photo-app && cd photo-app
uv init .
uv add fastapi uvicorn[standard] python-multipart
uv add sqlalchemy aiosqlite
uv add fastapi-users[sqlalchemy]
uv add streamlit  # for the test UI later

Hello world FastAPI and the auto-generated docs

app/main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def hello():
    return {"message": "hello world"}
Bash
uv run uvicorn app.main:app --reload
# open http://localhost:8000/docs   — auto-generated Swagger UI

The auto-generated Swagger UI at /docs is FastAPI’s killer feature for learning. Every route you add appears there, with a clickable interface to test it. No Postman needed during development.

First feature: text posts with an in-memory dict

Skip the database temporarily. Start with a Python dict to get the route shape right:

app/main.py
text_posts: dict[str, str] = {}

@app.get("/posts")
def get_posts():
    return text_posts

@app.post("/posts/{post_id}")
def create_post(post_id: str, content: str):
    text_posts[post_id] = content
    return {"ok": True}

Visit /docs, click each route, “Try it out,” see real responses. The routes work, but type safety and validation are weak — content as a raw query parameter falls apart the moment you need a structured body.

Pydantic models for request and response validation

Pydantic models define the shape of data crossing your API boundary. FastAPI uses them for validation, parsing, and (importantly) the auto-docs:

app/schemas.py
from datetime import datetime
from pydantic import BaseModel

class PostCreate(BaseModel):
    caption: str

class PostRead(BaseModel):
    id: str
    caption: str
    url: str
    file_type: str
    file_name: str
    created_at: str

Now the route signature carries types. FastAPI auto-validates incoming JSON against PostCreate and shapes the response against PostRead:

Python
@app.post("/posts", response_model=PostRead)
def create_post(payload: PostCreate):
    ...

Adding SQLAlchemy and async SQLite

Dicts in memory die on restart. Move to a real database:

app/db.py
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker

DATABASE_URL = "sqlite+aiosqlite:///./app.db"

engine = create_async_engine(DATABASE_URL)
SessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

class Base(DeclarativeBase):
    pass

async def get_async_session() -> AsyncSession:
    async with SessionLocal() as session:
        yield session

Swap to Postgres later by changing only the DATABASE_URL — everything else is portable.

The data model: User, Post, foreign keys

app/models.py
import uuid
from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, DateTime, ForeignKey
from fastapi_users.db import SQLAlchemyBaseUserTableUUID
from .db import Base

class User(SQLAlchemyBaseUserTableUUID, Base):
    pass

class Post(Base):
    __tablename__ = "posts"
    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user.id"))
    caption: Mapped[str] = mapped_column(String(280))
    url: Mapped[str] = mapped_column(String(500))
    file_type: Mapped[str] = mapped_column(String(50))
    file_name: Mapped[str] = mapped_column(String(255))
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

On app startup, create the tables once:

Python
@app.on_event("startup")
async def on_startup():
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

File uploads and media storage

Photos and videos arrive as multipart/form-data. FastAPI handles this with UploadFile:

Python
from fastapi import UploadFile, File, Form, Depends
from pathlib import Path

MEDIA = Path("media"); MEDIA.mkdir(exist_ok=True)

@app.post("/posts", response_model=PostRead)
async def create_post(
    caption: str = Form(...),
    file: UploadFile = File(...),
    session: AsyncSession = Depends(get_async_session),
    user: User = Depends(current_active_user),
):
    saved_name = f"{uuid.uuid4()}_{file.filename}"
    target = MEDIA / saved_name
    target.write_bytes(await file.read())

    post = Post(
        user_id=user.id, caption=caption,
        url=f"/media/{saved_name}",
        file_type=file.content_type, file_name=file.filename,
    )
    session.add(post)
    await session.commit()
    await session.refresh(post)
    return PostRead(
        id=str(post.id), caption=post.caption, url=post.url,
        file_type=post.file_type, file_name=post.file_name,
        created_at=post.created_at.isoformat(),
    )

For real production use, send files to S3, R2, or ImageKit instead of disk — serverless deployments have read-only filesystems and even traditional servers don’t scale well with local uploads.

Dependency injection: sessions, current user, permissions

The Depends(...) calls above are FastAPI’s dependency injection in action. Each dependency is a function FastAPI calls before your route runs — opens a DB session, fetches the current user from the JWT, raises 401 if missing. Your route only runs if all dependencies succeed.

This means your route bodies stay clean. No if not user: raise 401 at the top of every function. The auth happens in the dependency.

Auth with FastAPI Users and JWT

FastAPI Users gives you registration, login, password reset, and a current_active_user dependency out of the box. Wire it up with a JWT backend:

app/users.py
from fastapi_users import FastAPIUsers, schemas
from fastapi_users.authentication import (
    AuthenticationBackend, BearerTransport, JWTStrategy,
)
from .models import User
from .user_manager import get_user_manager   # standard FastAPI-Users boilerplate

SECRET = "change-me-in-production"
bearer = BearerTransport(tokenUrl="auth/jwt/login")

def get_jwt_strategy() -> JWTStrategy:
    return JWTStrategy(secret=SECRET, lifetime_seconds=3600)

backend = AuthenticationBackend(
    name="jwt", transport=bearer, get_strategy=get_jwt_strategy,
)

fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [backend])

class UserRead(schemas.BaseUser[uuid.UUID]): pass
class UserCreate(schemas.BaseUserCreate): pass
class UserUpdate(schemas.BaseUserUpdate): pass

current_active_user = fastapi_users.current_user(active=True)

Mount the standard auth routers in main.py:

Python
app.include_router(fastapi_users.get_auth_router(backend), prefix="/auth/jwt")
app.include_router(fastapi_users.get_register_router(UserRead, UserCreate), prefix="/auth")

Free for you: POST /auth/register, POST /auth/jwt/login returning a JWT, POST /auth/jwt/logout. The login response includes a token your frontend stores and sends back as Authorization: Bearer ... on subsequent requests.

Updating and deleting posts safely (IDOR defense)

The most common security bug in CRUD apps: forgetting to check that the resource belongs to the current user. Anyone can pass a different post_id and delete someone else’s data.

Python
from sqlalchemy import select
from fastapi import HTTPException

@app.delete("/posts/{post_id}", status_code=204)
async def delete_post(
    post_id: str,
    session: AsyncSession = Depends(get_async_session),
    user: User = Depends(current_active_user),
):
    pid = uuid.UUID(post_id)
    result = await session.execute(select(Post).where(Post.id == pid))
    post = result.scalars().first()
    if not post:
        raise HTTPException(404, "post not found")
    if post.user_id != user.id:
        raise HTTPException(403, "not your post")
    await session.delete(post)
    await session.commit()

Two checks: existence (404) and ownership (403). Run them on every endpoint that mutates user-owned data.

Building the Streamlit test UI

Skip building a React frontend for development — Streamlit is good enough for testing every endpoint:

ui.py
import streamlit as st, requests

API = "http://localhost:8000"
token = st.session_state.get("token")

if not token:
    email = st.text_input("email")
    pw = st.text_input("password", type="password")
    if st.button("login"):
        r = requests.post(f"{API}/auth/jwt/login",
                          data={"username": email, "password": pw})
        if r.ok:
            st.session_state.token = r.json()["access_token"]
            st.rerun()
else:
    headers = {"Authorization": f"Bearer {token}"}
    f = st.file_uploader("upload photo or video")
    caption = st.text_input("caption")
    if st.button("post") and f:
        requests.post(f"{API}/posts", headers=headers,
                      data={"caption": caption},
                      files={"file": (f.name, f.read(), f.type)})

    feed = requests.get(f"{API}/posts", headers=headers).json()
    for p in feed:
        st.image(f"{API}{p['url']}") if "image" in p["file_type"] else st.video(f"{API}{p['url']}")
        st.caption(p["caption"])

Run with uv run streamlit run ui.py. You now have a working photo-sharing app from nothing in a few hundred lines of code.

❌ Common mistakes

  • Skipping IDOR checks. A user-owned resource must verify ownership on every read/write/delete.
  • Storing files on local disk in production. Use S3, R2, ImageKit, or another object store.
  • Returning SQLAlchemy models directly instead of Pydantic schemas. Leaks internal fields and breaks the docs.
  • Forgetting to use response_model=. Without it, FastAPI returns whatever your function returns — including fields you didn’t mean to expose.
  • Hardcoding the JWT secret. Read from environment variables and rotate periodically.
  • Not adding indexes on foreign-key columns. Queries slow down with no obvious cause as the table grows.
  • Mixing sync and async carelessly. If you use aiosqlite + AsyncSession, every DB call should be awaited.

💡 Pro tips

  • Lean on /docs heavily during development — faster feedback than writing curl commands.
  • Use Alembic for migrations the moment the schema changes more than once.
  • For multi-developer projects, switch from SQLite to Postgres early. Connection-pool behavior differs enough to matter.
  • Set --reload only in dev. Production runs uvicorn behind gunicorn with multiple workers.
  • Add structured logging from day one (structlog or stdlib logging). Debugging async code without logs is painful.
  • Pair FastAPI with pytest + httpx.AsyncClient for endpoint tests. Fast to run, catches regressions cheaply.

Conclusion

One project covers most of what you’ll do with FastAPI in real work: routes, models, database, auth, file uploads, dependency injection, security. The patterns repeat for every other API you build — only the entities change.

Next steps for the photo app: pagination, comments, likes, search, S3 upload, deployment to Render or Fly.io, React frontend instead of Streamlit. Each one is a small extension of what you already have.

Learn FastAPI by Building a Photo & Video Sharing App (Auth, DB, Uploads, Streamlit UI) FAQ

Why FastAPI over Django or Flask?

Async-friendly, type-driven, smaller surface area than Django, more structure than Flask. The auto-generated docs and Pydantic validation are productivity multipliers. For API-first projects in 2026, FastAPI is the default choice.

SQLite or Postgres for production?

Postgres for anything multi-user. SQLite is fine for prototypes and single-writer workloads but doesn’t handle concurrent writes well. Migration is one connection-string change plus a data export/import.

How do I add background jobs?

For simple cases, FastAPI’s BackgroundTasks dependency runs work after the response is returned. For real background jobs, use Celery + Redis or RQ. Image processing, thumbnail generation, and email sending all belong in background workers.

Where do I deploy a FastAPI app?

Render and Fly.io are the easiest for small/medium apps. Vercel works for stateless serverless. For full control, Hetzner / DigitalOcean / Hostinger with Docker. Bigger scale: AWS ECS, Cloud Run, Kubernetes.

Do I need React for the front-end?

Not necessarily. Streamlit for internal tools, HTMX for traditional server-rendered pages, React/Vue/Svelte for full SPAs. Pick by what the product actually needs, not by what’s trendy.

How does this scale?

FastAPI itself scales well — it’s built on Starlette and Uvicorn, both production-grade. The bottleneck is usually the database. Add a read replica, cache hot queries in Redis, offload static media to a CDN. The architecture in this tutorial cleanly grows into a production system without rewriting.