Skip to content

FastAPI File Upload and Frontend Integration

This setup creates a FastAPI API for uploading files from a frontend application. It does not use Jinja templates or static HTML pages. The backend accepts multipart form uploads, saves files under a media directory, serves uploaded files through /media, enables CORS for a frontend origin, and returns JSON responses containing media URLs.

Create a separate project folder for file upload and frontend integration. This example is API-first and does not use server rendered HTML.

09-file-upload-cors-frontend/
app/
__init__.py
config.py
main.py
schemas.py
storage.py
media/
uploads/
requirements.txt
Dockerfile
compose.yaml
Terminal window
mkdir 09-file-upload-cors-frontend
cd 09-file-upload-cors-frontend
mkdir -p app media/uploads
touch app/__init__.py

Create the configuration file at app/config.py. This keeps upload paths, public media URL prefix, and frontend CORS origins in one place.

app/config.py
import os
APP_NAME = os.getenv("APP_NAME", "FastAPI File Upload API")
MEDIA_ROOT = os.getenv("MEDIA_ROOT", "media")
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "media/uploads")
MEDIA_URL = os.getenv("MEDIA_URL", "/media")
CORS_ORIGINS = [
origin.strip()
for origin in os.getenv(
"CORS_ORIGINS",
"http://localhost:3000,http://localhost:5173",
).split(",")
if origin.strip()
]
MAX_UPLOAD_SIZE = 5 * 1024 * 1024
ALLOWED_IMAGE_TYPES = {
"image/jpeg",
"image/png",
"image/webp",
}

The frontend origin is usually http://localhost:3000 for Next.js or http://localhost:5173 for Vite.

Create the schema file at app/schemas.py. The API returns JSON with the original filename, stored filename, content type, size, and public file URL.

app/schemas.py
from pydantic import BaseModel
class UploadResponse(BaseModel):
original_filename: str
stored_filename: str
content_type: str
size: int
url: str
class UploadListResponse(BaseModel):
files: list[str]

Create the storage helper file at app/storage.py. This file validates upload type and size, creates safe unique filenames, saves files to disk, and builds public media URLs.

app/storage.py
from pathlib import Path
from uuid import uuid4
from fastapi import HTTPException, UploadFile, status
from app.config import ALLOWED_IMAGE_TYPES, MAX_UPLOAD_SIZE, MEDIA_URL, UPLOAD_DIR
def ensure_upload_dir():
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
def get_file_extension(filename):
return Path(filename).suffix.lower()
def create_stored_filename(filename):
extension = get_file_extension(filename)
return f"{uuid4().hex}{extension}"
def validate_image_upload(file):
if file.content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only JPEG, PNG, and WEBP images are allowed",
)
def save_upload_file(file: UploadFile):
validate_image_upload(file)
ensure_upload_dir()
stored_filename = create_stored_filename(file.filename or "upload")
target_path = Path(UPLOAD_DIR) / stored_filename
size = 0
with target_path.open("wb") as buffer:
while True:
chunk = file.file.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > MAX_UPLOAD_SIZE:
target_path.unlink(missing_ok=True)
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Uploaded file is too large",
)
buffer.write(chunk)
return {
"original_filename": file.filename or "upload",
"stored_filename": stored_filename,
"content_type": file.content_type or "application/octet-stream",
"size": size,
"url": f"{MEDIA_URL}/uploads/{stored_filename}",
}
def list_uploaded_files():
ensure_upload_dir()
return sorted(
path.name
for path in Path(UPLOAD_DIR).iterdir()
if path.is_file()
)

The stored filename is generated with a UUID so user supplied filenames are not trusted as storage names.

Create the main application file at app/main.py. This app enables CORS, serves uploaded files under /media, and exposes API routes for uploading and listing files.

app/main.py
from fastapi import FastAPI, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import APP_NAME, CORS_ORIGINS, MEDIA_ROOT, MEDIA_URL
from app.schemas import UploadListResponse, UploadResponse
from app.storage import list_uploaded_files, save_upload_file
app = FastAPI(title=APP_NAME)
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount(
MEDIA_URL,
StaticFiles(directory=MEDIA_ROOT),
name="media",
)
@app.get("/")
def home():
return {
"message": "FastAPI file upload API"
}
@app.post("/uploads/images", response_model=UploadResponse)
def upload_image(file: UploadFile = File(...)):
return save_upload_file(file)
@app.get("/uploads/images", response_model=UploadListResponse)
def uploaded_images():
return {
"files": list_uploaded_files()
}

This uses StaticFiles only to serve uploaded media files. It does not create static HTML pages and does not use Jinja templates.

Create the dependency file at requirements.txt. python-multipart is required because file uploads use multipart/form-data.

requirements.txt
fastapi
uvicorn[standard]
python-multipart

Create the Dockerfile at the project root. The container owns the Python environment and runs the FastAPI app with Uvicorn.

Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --upgrade pip
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

Create the Docker Compose file at compose.yaml. Uploaded files are stored in a named Docker volume so they survive container restarts.

compose.yaml
services:
backend:
build: .
container_name: fastapi-09-file-upload-cors-frontend
ports:
- "8000:8000"
environment:
APP_NAME: FastAPI File Upload API
MEDIA_ROOT: media
UPLOAD_DIR: media/uploads
MEDIA_URL: /media
CORS_ORIGINS: http://localhost:3000,http://localhost:5173
volumes:
- .:/app
- uploaded_media:/app/media/uploads
volumes:
uploaded_media:

Run Docker Compose from inside the project folder.

Terminal window
docker compose up --build

Open the API docs page and test POST /uploads/images from the browser.

http://localhost:8000/docs

Use curl to test the upload endpoint without a frontend. The form field name must be file because the route parameter is named file.

Terminal window
curl -X POST http://localhost:8000/uploads/images \
-F "file=@./sample.png"

The response returns a JSON object containing the public media URL.

{
"original_filename": "sample.png",
"stored_filename": "b7e6f0a0d29a4c7a8c20c7d8c95d2f31.png",
"content_type": "image/png",
"size": 42155,
"url": "/media/uploads/b7e6f0a0d29a4c7a8c20c7d8c95d2f31.png"
}

Open the returned URL through the FastAPI server. The uploaded file is served as media, not as a static HTML page.

http://localhost:8000/media/uploads/b7e6f0a0d29a4c7a8c20c7d8c95d2f31.png

List uploaded files with GET /uploads/images.

http://localhost:8000/uploads/images

A frontend should send multipart/form-data with FormData. Do not manually set the Content-Type header because the browser adds the multipart boundary automatically.

async function uploadImage(file: File) {
const formData = new FormData()
formData.append("file", file)
const response = await fetch("http://localhost:8000/uploads/images", {
method: "POST",
body: formData,
})
if (!response.ok) {
throw new Error("Upload failed")
}
return response.json() as Promise<{
original_filename: string
stored_filename: string
content_type: string
size: number
url: string
}>
}

The backend returns a relative media URL such as /media/uploads/file.png. A separate frontend should prepend the API base URL before displaying the image.

const API_BASE_URL = "http://localhost:8000"
function buildMediaUrl(url: string) {
if (url.startsWith("http")) {
return url
}
return `${API_BASE_URL}${url}`
}

In a Next.js app, use the resulting absolute URL as the image source and allow the backend host in next.config if using next/image.

CORS is needed when the frontend and backend run on different origins. For example, Next.js may run on localhost:3000 while FastAPI runs on localhost:8000.

Frontend
http://localhost:3000
Backend
http://localhost:8000
Allowed origin
http://localhost:3000

The backend must explicitly allow the frontend origin. Otherwise, browser requests from the frontend can be blocked even when the API works from curl or Swagger UI.

File upload endpoints should not blindly trust user files. This example validates content type, limits upload size, and replaces the original filename with a generated storage filename.

Validate
content type
upload size
required form field
Avoid trusting
original filename
file extension alone
user supplied path
Store
generated filename
known upload directory
public URL in API response
For production
store metadata in database
use object storage for large files
scan risky file types when needed

Static media does not mean static HTML. In this setup, FastAPI only serves uploaded files so the frontend can display them.

Static HTML
not used here
Jinja templates
not used here
StaticFiles
used only for /media
API route
accepts upload
Media URL
returned as JSON
Frontend
uploads file and displays returned URL

Stop the running container without deleting uploaded files.

Terminal window
docker compose down

Reset the project by removing the Docker volume. This deletes uploaded media files.

Terminal window
docker compose down -v