July 8, 2026

Build a White-Label Multi-Tenant Restaurant Commerce SaaS with FastAPI, Next.js, PostgreSQL, and Stripe Connect
1. Introduction
A white-label SaaS platform lets one company build and operate software that other businesses use under their own brand. In the restaurant industry, this model is especially useful because many restaurants want online menus, pickup ordering, payments, reservations, images, promotions, and customer-facing pages without building a full engineering team.
In this article, you will build a minimal but complete white-label restaurant commerce platform. The platform is operated by you, but each restaurant tenant can use it as if it belongs to their own business. Customers see the restaurant's brand, domain, menu, prices, and checkout experience. Behind the scenes, every tenant runs on the same shared application infrastructure.
The project uses an API-first architecture with FastAPI, Next.js, PostgreSQL, Redis, Celery, Nginx, Docker Compose, Stripe Connect, and DigitalOcean Spaces. The implementation is intentionally small enough to understand, but it introduces the same architectural ideas used by larger SaaS products: tenant isolation, custom-domain routing, menu CRUD, order creation, media storage, connected payment accounts, background jobs, and reverse proxy routing.
By the end, you will have a runnable local project with:
- A FastAPI backend
- A Next.js storefront/admin-style frontend
- PostgreSQL database tables for tenants, restaurants, menu items, and orders
- Redis for caching and Celery broker support
- Celery worker configuration for background processing
- Nginx routing that preserves the tenant domain through the
Hostheader - Stripe Connect onboarding endpoint structure
- DigitalOcean Spaces-compatible media upload helper structure
- Docker Compose setup for local development
Official documentation confirms that FastAPI is designed for Python APIs based on type hints, Next.js supports the App Router for modern React applications, Stripe Connect uses connected accounts and onboarding links, and DigitalOcean Spaces provides S3-compatible object storage. Those product facts are reflected in the architecture choices used here.
2. Problem Statement
Many restaurants start with manual or fragmented systems:
- Menus are edited in PDFs, spreadsheets, or static web pages.
- Prices are changed manually across delivery apps, websites, and printed menus.
- Online orders arrive through email, phone calls, or third-party marketplaces.
- Payment processing is disconnected from the restaurant's own brand.
- Food photos are stored in random folders instead of a structured media library.
- Promotions and availability are updated by texting a developer or designer.
- Each restaurant website has its own one-off technical setup.
This approach becomes difficult to scale. Every new restaurant requires repeated setup work. Every menu change risks inconsistency. Every custom domain requires careful server configuration. Every payment workflow introduces compliance and payout concerns.
A white-label multi-tenant SaaS solves this by centralizing the platform while decentralizing the brand experience. One shared codebase supports many restaurants. Each tenant gets its own business profile, custom domain, menu, prices, order records, payment account connection, and storefront experience.
The intended users are:
- Platform owner: The company that builds and operates the SaaS.
- Restaurant tenant: A business that subscribes to the platform.
- Restaurant manager: A user who manages menu items, prices, availability, and orders.
- Customer: A visitor who browses the restaurant's branded storefront and places an order.
The value is clear: restaurants get a professional branded ordering experience, customers get a simple shopping flow, and the SaaS operator can onboard many businesses without duplicating infrastructure.
3. Solution Overview
The solution is a shared SaaS application that detects which restaurant tenant is being accessed based on the request domain. For example:
pizza.example.test -> Tenant: Luigi's Pizza
sushi.example.test -> Tenant: Sakura Sushi
localhost:3000 -> Development tenant selector or fallback tenant
The browser sends a request to the Next.js frontend. The frontend forwards API requests to FastAPI and includes the current browser host in an X-Tenant-Domain header. The backend resolves that domain to a tenant record in PostgreSQL. After the tenant is known, all restaurant, menu, and order queries are scoped to that tenant.
From a user perspective, the workflow looks like this:
- A restaurant subscribes to the platform.
- The platform creates a tenant record and associates one or more domains with it.
- The restaurant creates or updates its business profile.
- The restaurant adds menu items, prices, descriptions, categories, images, and availability.
- The restaurant connects a Stripe account for payouts.
- Customers visit the restaurant's own domain.
- Customers browse the menu and place orders.
- Background workers can process slow tasks such as order notifications, image processing, analytics, and synchronization.
The application's main capabilities are:
- Tenant-aware restaurant profiles
- Custom-domain tenant resolution
- Menu item CRUD
- Order creation and order status updates
- Stripe Connect onboarding endpoint structure
- DigitalOcean Spaces-compatible media upload helper
- Redis-backed caching pattern
- Celery-ready background job processing
- Docker Compose local environment
- Nginx reverse proxy configuration
4. Features
Core Features
| Feature | Description |
|---|---|
| Tenant resolution | Maps a request domain to a tenant record. |
| Restaurant profile | Stores each tenant's restaurant name, brand colors, contact information, and status. |
| Menu management | Allows each tenant to create, view, update, and delete menu items. |
| Ordering | Allows customers to submit basic orders against menu items. |
| Order status updates | Allows restaurant staff or the API to move orders through statuses such as pending, accepted, preparing, ready, completed, or cancelled. |
| Stripe Connect onboarding | Provides an endpoint where a tenant can start payment-account onboarding. |
| Media upload helper | Provides an endpoint structure for creating presigned upload URLs for S3-compatible storage such as DigitalOcean Spaces. |
| Background jobs | Uses Celery and Redis for asynchronous work such as order notifications or reporting. |
| Nginx routing | Preserves the original domain so the backend can resolve the correct tenant. |
CRUD Capabilities
Create
- Create tenants.
- Create restaurant profiles.
- Create menu items.
- Create orders.
- Create Stripe onboarding links when Stripe is configured.
- Create media upload URLs when Spaces is configured.
Read
- Read the current tenant by domain.
- Read restaurant profile information.
- Read active menu items for a tenant.
- Read order records.
- Read health-check status.
Update
- Update restaurant details such as name, phone, address, brand color, and active state.
- Update menu item names, descriptions, prices, categories, image URLs, and availability.
- Update order status.
- Update tenant Stripe account ID after onboarding.
Delete
- Delete menu items that should no longer appear.
- Soft-delete behavior is recommended for production, but this tutorial demonstrates a simple hard delete for menu items.
Validation, Error Handling, and Usability
The backend validates required fields with Pydantic schemas and SQL constraints. It returns clear HTTP status codes for missing tenants, invalid menu items, unavailable items, and missing configuration. The frontend displays loading states, form errors, and simple success messages. The database uses foreign keys and indexes to keep tenant-scoped queries efficient and consistent.
5. Technology Stack
This project intentionally uses a production-oriented stack instead of a single-file tutorial stack. The uploaded requirement mentions a default Flask/SQLite fallback for unspecified projects, but this specific topic explicitly requires FastAPI, Next.js, PostgreSQL, Redis, Celery, Nginx, Docker Compose, Stripe Connect, and DigitalOcean Spaces. Therefore, the implementation below uses the requested stack consistently.
| Technology | Role | Why It Was Selected |
|---|---|---|
| Python | Backend programming language | Python is readable, widely adopted, and well suited for API development and background jobs. |
| FastAPI | API backend | FastAPI provides typed request/response validation, automatic OpenAPI documentation, and strong ergonomics for API-first systems. |
| Next.js | Frontend application | Next.js supports modern React application structure and can serve branded tenant-facing pages. |
| PostgreSQL | Primary database | PostgreSQL is a reliable relational database for multi-tenant SaaS data, constraints, indexes, and transactions. |
| Redis | Cache and Celery broker | Redis is useful for fast tenant lookups, caching, queues, and background job coordination. |
| Celery | Background jobs | Celery processes asynchronous tasks such as notifications, reports, media processing, and integrations. |
| Nginx | Reverse proxy and routing | Nginx receives HTTP traffic, serves as an entry point, and forwards requests while preserving the original host. |
| Docker Compose | Local orchestration | Docker Compose runs the API, frontend, database, cache, worker, and proxy together for local development. |
| Stripe Connect | Tenant payment onboarding | Stripe Connect allows the platform to connect restaurant accounts and route payment flows to tenant accounts. |
| DigitalOcean Spaces | Media/object storage | Spaces provides S3-compatible object storage for menu images, logos, and media assets. |
| SQLAlchemy | Database ORM | SQLAlchemy maps Python models to PostgreSQL tables and keeps queries maintainable. |
| Pydantic | Data validation | Pydantic validates API payloads and response models. |
6. Architecture Plan
Architecture Diagram
+--------------------------+
| Customer or Manager |
| Browser |
+------------+-------------+
|
| restaurant domain / custom host
v
+------------------+ +------------+-------------+
| Restaurant A | | Nginx Reverse Proxy |
| pizza.localhost +------------->| Preserves Host Header |
+------------------+ +------------+-------------+
|
+------------------+ |
| Restaurant B | v
| sushi.localhost | +------------+-------------+
+------------------+ | Next.js Frontend |
| Tenant-aware UI |
+------------+-------------+
|
| API requests with X-Tenant-Domain
v
+------------+-------------+
| FastAPI Backend |
| Tenant resolution |
| Restaurant/menu/order API|
+------+-----+------+------+
| |
SQL queries | | background tasks
v v
+---------------+--+ +---+-------------+
| PostgreSQL | | Redis + Celery |
| SaaS data | | Queue/cache |
+------------------+ +-----------------+
|
| optional integrations
v
+----------------+----------------+
| Stripe Connect + DigitalOcean |
| Spaces-compatible object store |
+---------------------------------+
Responsibility Breakdown
| Layer | Responsibility |
|---|---|
| Browser / UI | Displays the restaurant storefront, menu forms, order form, and tenant-specific branding. |
| Nginx | Accepts inbound HTTP traffic and forwards requests to the frontend or API while preserving the original Host header. |
| Next.js | Renders the user interface and sends tenant-aware API requests to FastAPI. |
| FastAPI | Validates requests, resolves the tenant, runs business logic, and returns JSON responses. |
| API endpoints | Provide REST-style operations for tenants, restaurants, menu items, orders, media, and payments. |
| Database layer | Uses SQLAlchemy models and sessions to query PostgreSQL safely. |
| PostgreSQL | Stores tenants, domains, restaurant profiles, menu items, and orders. |
| Redis | Supports caching and Celery message brokering. |
| Celery | Runs slow or asynchronous tasks outside the request/response cycle. |
| Stripe Connect | Handles tenant payment-account onboarding and future checkout routing. |
| DigitalOcean Spaces | Stores restaurant logos, menu images, and uploaded media assets. |
Request Flow
- A customer opens
pizza.localhostor a real restaurant domain. - Nginx forwards the request to the Next.js frontend.
- Next.js reads the current host and calls the FastAPI backend.
- The API receives the tenant domain through
X-Tenant-Domain. - FastAPI looks up the matching tenant in PostgreSQL.
- The backend filters all restaurant, menu, and order operations by
tenant_id. - The API returns tenant-scoped data.
- Next.js renders the restaurant-specific experience.
Recommended Project Folder Structure
white-label-restaurant-saas/
├── docker-compose.yml
├── .env.example
├── nginx/
│ └── default.conf
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── schema.sql
│ ├── seed.py
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py
│ │ ├── config.py
│ │ ├── database.py
│ │ ├── models.py
│ │ ├── schemas.py
│ │ ├── tenant.py
│ │ ├── tasks.py
│ │ └── routers/
│ │ ├── __init__.py
│ │ ├── health.py
│ │ ├── tenants.py
│ │ ├── restaurants.py
│ │ ├── menu_items.py
│ │ ├── orders.py
│ │ ├── payments.py
│ │ └── media.py
└── frontend/
├── Dockerfile
├── package.json
├── next.config.js
├── tsconfig.json
└── app/
├── globals.css
├── layout.tsx
├── page.tsx
└── components/
└── MenuManager.tsx
7. Database Design
The database uses a shared-schema multi-tenant model. All tenant-owned records include a tenant_id foreign key. This approach is simple for a starter SaaS and works well when every tenant uses the same schema.
For higher compliance or enterprise isolation needs, you could later move to separate schemas or separate databases per tenant. For this tutorial, shared schema plus strict tenant scoping is easier to run locally and understand.
Tables
| Table | Purpose |
|---|---|
tenants | Stores each subscribed business tenant. |
tenant_domains | Maps custom domains or subdomains to tenants. |
restaurants | Stores restaurant profile and branding details for each tenant. |
menu_items | Stores tenant-specific menu items, descriptions, prices, categories, and images. |
orders | Stores customer orders. |
order_items | Stores individual menu items inside each order. |
Column Documentation
| Table | Column | Data Type | Primary Key | Foreign Key | Default | Constraint | Purpose |
|---|---|---|---|---|---|---|---|
tenants | id | SERIAL | Yes | No | Auto | Not null | Unique tenant identifier. |
tenants | name | VARCHAR(120) | No | No | None | Not null | Tenant business name. |
tenants | slug | VARCHAR(80) | No | No | None | Unique, not null | URL-safe tenant identifier. |
tenants | stripe_account_id | VARCHAR(120) | No | No | Null | None | Stripe connected account ID. |
tenants | is_active | BOOLEAN | No | No | TRUE | Not null | Controls whether tenant is active. |
tenants | created_at | TIMESTAMPTZ | No | No | NOW() | Not null | Creation timestamp. |
tenant_domains | id | SERIAL | Yes | No | Auto | Not null | Unique domain mapping ID. |
tenant_domains | tenant_id | INTEGER | No | tenants.id | None | Cascade delete | Owner tenant. |
tenant_domains | domain | VARCHAR(255) | No | No | None | Unique, not null | Custom domain or local test host. |
tenant_domains | is_primary | BOOLEAN | No | No | FALSE | Not null | Identifies preferred domain. |
restaurants | id | SERIAL | Yes | No | Auto | Not null | Restaurant profile ID. |
restaurants | tenant_id | INTEGER | No | tenants.id | None | Unique, cascade delete | Tenant owner. |
restaurants | name | VARCHAR(160) | No | No | None | Not null | Customer-facing restaurant name. |
restaurants | description | TEXT | No | No | Empty string | Not null | Public restaurant description. |
restaurants | phone | VARCHAR(40) | No | No | Empty string | Not null | Contact phone. |
restaurants | address | TEXT | No | No | Empty string | Not null | Public address. |
restaurants | brand_color | VARCHAR(20) | No | No | #b91c1c | Not null | Main storefront color. |
restaurants | logo_url | TEXT | No | No | Empty string | Not null | Logo image URL. |
restaurants | is_accepting_orders | BOOLEAN | No | No | TRUE | Not null | Controls customer ordering. |
menu_items | id | SERIAL | Yes | No | Auto | Not null | Menu item ID. |
menu_items | tenant_id | INTEGER | No | tenants.id | None | Cascade delete | Tenant owner. |
menu_items | name | VARCHAR(160) | No | No | None | Not null | Menu item name. |
menu_items | description | TEXT | No | No | Empty string | Not null | Menu item description. |
menu_items | category | VARCHAR(80) | No | No | General | Not null | Menu category. |
menu_items | price_cents | INTEGER | No | No | None | price_cents >= 0 | Price stored in cents. |
menu_items | image_url | TEXT | No | No | Empty string | Not null | Menu item image URL. |
menu_items | is_available | BOOLEAN | No | No | TRUE | Not null | Controls visibility/orderability. |
menu_items | created_at | TIMESTAMPTZ | No | No | NOW() | Not null | Creation timestamp. |
orders | id | SERIAL | Yes | No | Auto | Not null | Order ID. |
orders | tenant_id | INTEGER | No | tenants.id | None | Cascade delete | Tenant owner. |
orders | customer_name | VARCHAR(120) | No | No | None | Not null | Customer name. |
orders | customer_phone | VARCHAR(40) | No | No | Empty string | Not null | Customer phone. |
orders | status | VARCHAR(40) | No | No | pending | Status check | Order workflow status. |
orders | total_cents | INTEGER | No | No | 0 | total_cents >= 0 | Calculated order total. |
orders | created_at | TIMESTAMPTZ | No | No | NOW() | Not null | Order timestamp. |
order_items | id | SERIAL | Yes | No | Auto | Not null | Order line item ID. |
order_items | order_id | INTEGER | No | orders.id | None | Cascade delete | Parent order. |
order_items | menu_item_id | INTEGER | No | menu_items.id | None | Restrict delete | Ordered menu item. |
order_items | quantity | INTEGER | No | No | None | quantity > 0 | Quantity ordered. |
order_items | unit_price_cents | INTEGER | No | No | None | unit_price_cents >= 0 | Price snapshot at order time. |
Complete PostgreSQL Schema
backend/schema.sql
CREATE TABLE IF NOT EXISTS tenants (
id SERIAL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
slug VARCHAR(80) NOT NULL UNIQUE,
stripe_account_id VARCHAR(120),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tenant_domains (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
domain VARCHAR(255) NOT NULL UNIQUE,
is_primary BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS restaurants (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(160) NOT NULL,
description TEXT NOT NULL DEFAULT '',
phone VARCHAR(40) NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
brand_color VARCHAR(20) NOT NULL DEFAULT '#b91c1c',
logo_url TEXT NOT NULL DEFAULT '',
is_accepting_orders BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS menu_items (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(160) NOT NULL,
description TEXT NOT NULL DEFAULT '',
category VARCHAR(80) NOT NULL DEFAULT 'General',
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
image_url TEXT NOT NULL DEFAULT '',
is_available BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
customer_name VARCHAR(120) NOT NULL,
customer_phone VARCHAR(40) NOT NULL DEFAULT '',
status VARCHAR(40) NOT NULL DEFAULT 'pending',
total_cents INTEGER NOT NULL DEFAULT 0 CHECK (total_cents >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT orders_status_check CHECK (
status IN ('pending', 'accepted', 'preparing', 'ready', 'completed', 'cancelled')
)
);
CREATE TABLE IF NOT EXISTS order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
menu_item_id INTEGER NOT NULL REFERENCES menu_items(id) ON DELETE RESTRICT,
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_cents INTEGER NOT NULL CHECK (unit_price_cents >= 0)
);
CREATE INDEX IF NOT EXISTS idx_tenant_domains_domain ON tenant_domains(domain);
CREATE INDEX IF NOT EXISTS idx_restaurants_tenant_id ON restaurants(tenant_id);
CREATE INDEX IF NOT EXISTS idx_menu_items_tenant_category ON menu_items(tenant_id, category);
CREATE INDEX IF NOT EXISTS idx_menu_items_tenant_available ON menu_items(tenant_id, is_available);
CREATE INDEX IF NOT EXISTS idx_orders_tenant_status_created ON orders(tenant_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
Seed Data
The sample data creates two tenants: Luigi's Pizza and Sakura Sushi. Each tenant has its own local test domain, restaurant profile, and menu items.
INSERT INTO tenants (name, slug, stripe_account_id, is_active)
VALUES
('Luigi''s Pizza', 'luigis-pizza', NULL, TRUE),
('Sakura Sushi', 'sakura-sushi', NULL, TRUE)
ON CONFLICT (slug) DO NOTHING;
INSERT INTO tenant_domains (tenant_id, domain, is_primary)
SELECT id, 'pizza.localhost', TRUE FROM tenants WHERE slug = 'luigis-pizza'
ON CONFLICT (domain) DO NOTHING;
INSERT INTO tenant_domains (tenant_id, domain, is_primary)
SELECT id, 'sushi.localhost', TRUE FROM tenants WHERE slug = 'sakura-sushi'
ON CONFLICT (domain) DO NOTHING;
INSERT INTO restaurants (tenant_id, name, description, phone, address, brand_color, logo_url, is_accepting_orders)
SELECT id, 'Luigi''s Pizza', 'Wood-fired pizza, fresh pasta, and neighborhood comfort food.', '(555) 201-1100', '18 Market Street, Sample City', '#b91c1c', '', TRUE
FROM tenants WHERE slug = 'luigis-pizza'
ON CONFLICT (tenant_id) DO NOTHING;
INSERT INTO restaurants (tenant_id, name, description, phone, address, brand_color, logo_url, is_accepting_orders)
SELECT id, 'Sakura Sushi', 'Fresh sushi rolls, bento boxes, and Japanese comfort dishes.', '(555) 302-2200', '42 Harbor Avenue, Sample City', '#0f766e', '', TRUE
FROM tenants WHERE slug = 'sakura-sushi'
ON CONFLICT (tenant_id) DO NOTHING;
INSERT INTO menu_items (tenant_id, name, description, category, price_cents, image_url, is_available)
SELECT id, 'Margherita Pizza', 'San Marzano tomato, fresh mozzarella, basil, and olive oil.', 'Pizza', 1499, '', TRUE
FROM tenants WHERE slug = 'luigis-pizza';
INSERT INTO menu_items (tenant_id, name, description, category, price_cents, image_url, is_available)
SELECT id, 'Spicy Rigatoni', 'Rigatoni pasta tossed with spicy tomato cream sauce.', 'Pasta', 1699, '', TRUE
FROM tenants WHERE slug = 'luigis-pizza';
INSERT INTO menu_items (tenant_id, name, description, category, price_cents, image_url, is_available)
SELECT id, 'Dragon Roll', 'Shrimp tempura, avocado, cucumber, eel sauce, and sesame.', 'Sushi Rolls', 1599, '', TRUE
FROM tenants WHERE slug = 'sakura-sushi';
INSERT INTO menu_items (tenant_id, name, description, category, price_cents, image_url, is_available)
SELECT id, 'Salmon Bento', 'Grilled salmon, rice, salad, miso soup, and seasonal sides.', 'Bento', 1899, '', TRUE
FROM tenants WHERE slug = 'sakura-sushi';
Why the Indexes and Constraints Exist
| Index or Constraint | Reason |
|---|---|
tenant_domains.domain UNIQUE | Prevents two tenants from claiming the same domain. |
tenants.slug UNIQUE | Provides a stable tenant identifier. |
restaurants.tenant_id UNIQUE | Keeps this starter project to one restaurant profile per tenant. |
menu_items.price_cents >= 0 | Prevents negative prices. |
orders.status CHECK | Prevents unsupported order workflow states. |
idx_tenant_domains_domain | Speeds up tenant lookup by domain. |
idx_menu_items_tenant_category | Speeds up tenant-scoped menu filtering. |
idx_orders_tenant_status_created | Speeds up tenant-scoped order dashboards. |
8. Route Map / API Design
| Method | Path | Purpose | Required Input | Expected Response |
|---|---|---|---|---|
GET | /health | Health check | None | { "status": "ok" } |
POST | /api/tenants | Create tenant | JSON: name, slug, domain | Tenant object |
GET | /api/tenants/current | Resolve current tenant by domain | X-Tenant-Domain header | Tenant object |
GET | /api/restaurants/current | Read tenant restaurant profile | X-Tenant-Domain header | Restaurant object |
PUT | /api/restaurants/current | Update tenant restaurant profile | Header plus JSON restaurant fields | Updated restaurant object |
GET | /api/menu-items | List tenant menu items | X-Tenant-Domain header | List of menu items |
POST | /api/menu-items | Create menu item | Header plus JSON: name, description, category, price_cents, image_url, is_available | Created menu item |
GET | /api/menu-items/{item_id} | Read one menu item | Header plus path ID | Menu item object |
PUT | /api/menu-items/{item_id} | Update menu item | Header, path ID, JSON fields | Updated menu item |
DELETE | /api/menu-items/{item_id} | Delete menu item | Header plus path ID | 204 No Content |
POST | /api/orders | Create customer order | Header plus JSON customer and line items | Created order summary |
GET | /api/orders | List tenant orders | Header | List of orders |
PUT | /api/orders/{order_id}/status | Update order status | Header, path ID, JSON: status | Updated order |
POST | /api/payments/connect/onboarding-link | Create Stripe onboarding link | Header | URL response or configuration message |
POST | /api/media/presigned-upload | Create Spaces upload URL | Header, JSON: filename, content_type | Upload URL response or configuration message |
Example Create Menu Item Request
{
"name": "Mushroom Truffle Pizza",
"description": "Roasted mushrooms, mozzarella, truffle oil, garlic, and parsley.",
"category": "Pizza",
"price_cents": 1899,
"image_url": "",
"is_available": true
}
Example Create Order Request
{
"customer_name": "Maya Rivera",
"customer_phone": "555-991-0101",
"items": [
{ "menu_item_id": 1, "quantity": 2 },
{ "menu_item_id": 2, "quantity": 1 }
]
}
9. Step-by-Step Implementation Guide
Step 1: Create the Project Directory
mkdir white-label-restaurant-saas
cd white-label-restaurant-saas
mkdir -p backend/app/routers frontend/app/components nginx
Step 2: Create the Environment File
cp .env.example .env
For local development, the default values in .env.example are enough to run the core project. Stripe and DigitalOcean Spaces endpoints will return a friendly configuration message until real credentials are added.
Step 3: Start the Full Stack
docker compose up --build
This starts:
- PostgreSQL on port
5432 - Redis on port
6379 - FastAPI on port
8000 - Next.js on port
3000 - Nginx on port
8080 - Celery worker connected to Redis
Step 4: Initialize the Database
Open a second terminal from the project root:
docker compose exec backend python seed.py
The seed script creates tables and inserts sample tenants, domains, restaurants, and menu items.
Step 5: Test the API Directly
curl -H "X-Tenant-Domain: pizza.localhost" http://localhost:8000/api/tenants/current
curl -H "X-Tenant-Domain: pizza.localhost" http://localhost:8000/api/menu-items
curl -H "X-Tenant-Domain: sushi.localhost" http://localhost:8000/api/menu-items
Step 6: Open the Frontend
Open:
http://localhost:3000
The frontend uses localhost as a fallback to pizza.localhost during development. You can also test the Nginx path:
http://localhost:8080
Step 7: Verify CRUD
Use the page form to create a menu item. Edit item availability or delete an item by using the displayed controls. Refresh the page to confirm data persists in PostgreSQL.
10. Complete Runnable Source Code
The following files make a minimal but complete runnable project.
white-label-restaurant-saas/.env.example
POSTGRES_DB=restaurant_saas
POSTGRES_USER=restaurant_user
POSTGRES_PASSWORD=restaurant_password
DATABASE_URL=postgresql+psycopg2://restaurant_user:restaurant_password@db:5432/restaurant_saas
REDIS_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/1
CELERY_RESULT_BACKEND=redis://redis:6379/2
API_BASE_URL=http://backend:8000
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
DEV_FALLBACK_TENANT_DOMAIN=pizza.localhost
STRIPE_SECRET_KEY=
STRIPE_CONNECT_RETURN_URL=http://localhost:3000/payments/return
STRIPE_CONNECT_REFRESH_URL=http://localhost:3000/payments/refresh
SPACES_ENDPOINT_URL=
SPACES_REGION=nyc3
SPACES_BUCKET=
SPACES_ACCESS_KEY_ID=
SPACES_SECRET_ACCESS_KEY=
white-label-restaurant-saas/docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-restaurant_saas}
POSTGRES_USER: ${POSTGRES_USER:-restaurant_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-restaurant_password}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-restaurant_user} -d ${POSTGRES_DB:-restaurant_saas}"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
ports:
- "6379:6379"
backend:
build: ./backend
env_file: .env
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
worker:
build: ./backend
env_file: .env
depends_on:
- backend
- redis
volumes:
- ./backend:/app
command: celery -A app.tasks.celery_app worker --loglevel=info
frontend:
build: ./frontend
env_file: .env
ports:
- "3000:3000"
depends_on:
- backend
volumes:
- ./frontend:/app
- /app/node_modules
- /app/.next
command: npm run dev
nginx:
image: nginx:1.27-alpine
ports:
- "8080:80"
depends_on:
- frontend
- backend
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
volumes:
postgres_data:
white-label-restaurant-saas/nginx/default.conf
server {
listen 80;
server_name _;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Tenant-Domain $host;
}
location /health {
proxy_pass http://backend:8000/health;
proxy_set_header Host $host;
}
location / {
proxy_pass http://frontend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
white-label-restaurant-saas/backend/Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
white-label-restaurant-saas/backend/requirements.txt
fastapi==0.115.6
uvicorn[standard]==0.34.0
SQLAlchemy==2.0.36
psycopg2-binary==2.9.10
pydantic-settings==2.7.1
python-multipart==0.0.20
redis==5.2.1
celery==5.4.0
stripe==11.4.1
boto3==1.35.90
white-label-restaurant-saas/backend/schema.sql
CREATE TABLE IF NOT EXISTS tenants (
id SERIAL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
slug VARCHAR(80) NOT NULL UNIQUE,
stripe_account_id VARCHAR(120),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tenant_domains (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
domain VARCHAR(255) NOT NULL UNIQUE,
is_primary BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS restaurants (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(160) NOT NULL,
description TEXT NOT NULL DEFAULT '',
phone VARCHAR(40) NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
brand_color VARCHAR(20) NOT NULL DEFAULT '#b91c1c',
logo_url TEXT NOT NULL DEFAULT '',
is_accepting_orders BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS menu_items (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(160) NOT NULL,
description TEXT NOT NULL DEFAULT '',
category VARCHAR(80) NOT NULL DEFAULT 'General',
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
image_url TEXT NOT NULL DEFAULT '',
is_available BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
customer_name VARCHAR(120) NOT NULL,
customer_phone VARCHAR(40) NOT NULL DEFAULT '',
status VARCHAR(40) NOT NULL DEFAULT 'pending',
total_cents INTEGER NOT NULL DEFAULT 0 CHECK (total_cents >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT orders_status_check CHECK (
status IN ('pending', 'accepted', 'preparing', 'ready', 'completed', 'cancelled')
)
);
CREATE TABLE IF NOT EXISTS order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
menu_item_id INTEGER NOT NULL REFERENCES menu_items(id) ON DELETE RESTRICT,
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_cents INTEGER NOT NULL CHECK (unit_price_cents >= 0)
);
CREATE INDEX IF NOT EXISTS idx_tenant_domains_domain ON tenant_domains(domain);
CREATE INDEX IF NOT EXISTS idx_restaurants_tenant_id ON restaurants(tenant_id);
CREATE INDEX IF NOT EXISTS idx_menu_items_tenant_category ON menu_items(tenant_id, category);
CREATE INDEX IF NOT EXISTS idx_menu_items_tenant_available ON menu_items(tenant_id, is_available);
CREATE INDEX IF NOT EXISTS idx_orders_tenant_status_created ON orders(tenant_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
white-label-restaurant-saas/backend/seed.py
from sqlalchemy import text
from app.database import SessionLocal, engine
from app.models import Base
def seed() -> None:
Base.metadata.create_all(bind=engine)
statements = [
"""
INSERT INTO tenants (name, slug, stripe_account_id, is_active)
VALUES
('Luigi''s Pizza', 'luigis-pizza', NULL, TRUE),
('Sakura Sushi', 'sakura-sushi', NULL, TRUE)
ON CONFLICT (slug) DO NOTHING;
""",
"""
INSERT INTO tenant_domains (tenant_id, domain, is_primary)
SELECT id, 'pizza.localhost', TRUE FROM tenants WHERE slug = 'luigis-pizza'
ON CONFLICT (domain) DO NOTHING;
""",
"""
INSERT INTO tenant_domains (tenant_id, domain, is_primary)
SELECT id, 'sushi.localhost', TRUE FROM tenants WHERE slug = 'sakura-sushi'
ON CONFLICT (domain) DO NOTHING;
""",
"""
INSERT INTO restaurants (tenant_id, name, description, phone, address, brand_color, logo_url, is_accepting_orders)
SELECT id, 'Luigi''s Pizza', 'Wood-fired pizza, fresh pasta, and neighborhood comfort food.', '(555) 201-1100', '18 Market Street, Sample City', '#b91c1c', '', TRUE
FROM tenants WHERE slug = 'luigis-pizza'
ON CONFLICT (tenant_id) DO NOTHING;
""",
"""
INSERT INTO restaurants (tenant_id, name, description, phone, address, brand_color, logo_url, is_accepting_orders)
SELECT id, 'Sakura Sushi', 'Fresh sushi rolls, bento boxes, and Japanese comfort dishes.', '(555) 302-2200', '42 Harbor Avenue, Sample City', '#0f766e', '', TRUE
FROM tenants WHERE slug = 'sakura-sushi'
ON CONFLICT (tenant_id) DO NOTHING;
""",
"""
INSERT INTO menu_items (tenant_id, name, description, category, price_cents, image_url, is_available)
SELECT id, 'Margherita Pizza', 'San Marzano tomato, fresh mozzarella, basil, and olive oil.', 'Pizza', 1499, '', TRUE
FROM tenants WHERE slug = 'luigis-pizza';
""",
"""
INSERT INTO menu_items (tenant_id, name, description, category, price_cents, image_url, is_available)
SELECT id, 'Spicy Rigatoni', 'Rigatoni pasta tossed with spicy tomato cream sauce.', 'Pasta', 1699, '', TRUE
FROM tenants WHERE slug = 'luigis-pizza';
""",
"""
INSERT INTO menu_items (tenant_id, name, description, category, price_cents, image_url, is_available)
SELECT id, 'Dragon Roll', 'Shrimp tempura, avocado, cucumber, eel sauce, and sesame.', 'Sushi Rolls', 1599, '', TRUE
FROM tenants WHERE slug = 'sakura-sushi';
""",
"""
INSERT INTO menu_items (tenant_id, name, description, category, price_cents, image_url, is_available)
SELECT id, 'Salmon Bento', 'Grilled salmon, rice, salad, miso soup, and seasonal sides.', 'Bento', 1899, '', TRUE
FROM tenants WHERE slug = 'sakura-sushi';
""",
]
with SessionLocal() as db:
for statement in statements:
db.execute(text(statement))
db.commit()
print("Database initialized and sample data inserted.")
if __name__ == "__main__":
seed()
white-label-restaurant-saas/backend/app/__init__.py
# Package marker for the FastAPI application.
white-label-restaurant-saas/backend/app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str
redis_url: str = "redis://redis:6379/0"
celery_broker_url: str = "redis://redis:6379/1"
celery_result_backend: str = "redis://redis:6379/2"
dev_fallback_tenant_domain: str = "pizza.localhost"
stripe_secret_key: str = ""
stripe_connect_return_url: str = "http://localhost:3000/payments/return"
stripe_connect_refresh_url: str = "http://localhost:3000/payments/refresh"
spaces_endpoint_url: str = ""
spaces_region: str = "nyc3"
spaces_bucket: str = ""
spaces_access_key_id: str = ""
spaces_secret_access_key: str = ""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = Settings()
white-label-restaurant-saas/backend/app/database.py
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
white-label-restaurant-saas/backend/app/models.py
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
slug: Mapped[str] = mapped_column(String(80), unique=True, nullable=False, index=True)
stripe_account_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
domains: Mapped[list["TenantDomain"]] = relationship(back_populates="tenant", cascade="all, delete-orphan")
restaurant: Mapped["Restaurant"] = relationship(back_populates="tenant", cascade="all, delete-orphan")
class TenantDomain(Base):
__tablename__ = "tenant_domains"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
domain: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
tenant: Mapped[Tenant] = relationship(back_populates="domains")
class Restaurant(Base):
__tablename__ = "restaurants"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(160), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
phone: Mapped[str] = mapped_column(String(40), nullable=False, default="")
address: Mapped[str] = mapped_column(Text, nullable=False, default="")
brand_color: Mapped[str] = mapped_column(String(20), nullable=False, default="#b91c1c")
logo_url: Mapped[str] = mapped_column(Text, nullable=False, default="")
is_accepting_orders: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
tenant: Mapped[Tenant] = relationship(back_populates="restaurant")
class MenuItem(Base):
__tablename__ = "menu_items"
__table_args__ = (CheckConstraint("price_cents >= 0", name="menu_items_price_check"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(160), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
category: Mapped[str] = mapped_column(String(80), nullable=False, default="General")
price_cents: Mapped[int] = mapped_column(Integer, nullable=False)
image_url: Mapped[str] = mapped_column(Text, nullable=False, default="")
is_available: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class Order(Base):
__tablename__ = "orders"
__table_args__ = (
CheckConstraint("total_cents >= 0", name="orders_total_check"),
CheckConstraint(
"status IN ('pending', 'accepted', 'preparing', 'ready', 'completed', 'cancelled')",
name="orders_status_check",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
customer_name: Mapped[str] = mapped_column(String(120), nullable=False)
customer_phone: Mapped[str] = mapped_column(String(40), nullable=False, default="")
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending")
total_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
items: Mapped[list["OrderItem"]] = relationship(cascade="all, delete-orphan")
class OrderItem(Base):
__tablename__ = "order_items"
__table_args__ = (
CheckConstraint("quantity > 0", name="order_items_quantity_check"),
CheckConstraint("unit_price_cents >= 0", name="order_items_price_check"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, index=True)
menu_item_id: Mapped[int] = mapped_column(ForeignKey("menu_items.id", ondelete="RESTRICT"), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
unit_price_cents: Mapped[int] = mapped_column(Integer, nullable=False)
white-label-restaurant-saas/backend/app/schemas.py
from pydantic import BaseModel, Field
class TenantCreate(BaseModel):
name: str = Field(min_length=2, max_length=120)
slug: str = Field(min_length=2, max_length=80, pattern=r"^[a-z0-9-]+$")
domain: str = Field(min_length=3, max_length=255)
class TenantRead(BaseModel):
id: int
name: str
slug: str
stripe_account_id: str | None
is_active: bool
model_config = {"from_attributes": True}
class RestaurantRead(BaseModel):
id: int
tenant_id: int
name: str
description: str
phone: str
address: str
brand_color: str
logo_url: str
is_accepting_orders: bool
model_config = {"from_attributes": True}
class RestaurantUpdate(BaseModel):
name: str = Field(min_length=2, max_length=160)
description: str = ""
phone: str = ""
address: str = ""
brand_color: str = Field(default="#b91c1c", max_length=20)
logo_url: str = ""
is_accepting_orders: bool = True
class MenuItemCreate(BaseModel):
name: str = Field(min_length=2, max_length=160)
description: str = ""
category: str = Field(default="General", min_length=2, max_length=80)
price_cents: int = Field(ge=0)
image_url: str = ""
is_available: bool = True
class MenuItemUpdate(MenuItemCreate):
pass
class MenuItemRead(BaseModel):
id: int
tenant_id: int
name: str
description: str
category: str
price_cents: int
image_url: str
is_available: bool
model_config = {"from_attributes": True}
class OrderLineCreate(BaseModel):
menu_item_id: int
quantity: int = Field(gt=0, le=50)
class OrderCreate(BaseModel):
customer_name: str = Field(min_length=2, max_length=120)
customer_phone: str = Field(default="", max_length=40)
items: list[OrderLineCreate] = Field(min_length=1)
class OrderStatusUpdate(BaseModel):
status: str = Field(pattern=r"^(pending|accepted|preparing|ready|completed|cancelled)$")
class OrderItemRead(BaseModel):
id: int
menu_item_id: int
quantity: int
unit_price_cents: int
model_config = {"from_attributes": True}
class OrderRead(BaseModel):
id: int
tenant_id: int
customer_name: str
customer_phone: str
status: str
total_cents: int
items: list[OrderItemRead] = []
model_config = {"from_attributes": True}
class PresignedUploadRequest(BaseModel):
filename: str = Field(min_length=3, max_length=200)
content_type: str = Field(min_length=3, max_length=120)
white-label-restaurant-saas/backend/app/tenant.py
from fastapi import Header, HTTPException, Request
from sqlalchemy.orm import Session
from app.config import settings
from app.models import Tenant, TenantDomain
def normalize_domain(value: str) -> str:
domain = value.strip().lower()
if ":" in domain:
domain = domain.split(":", 1)[0]
return domain
def get_tenant_from_request(
request: Request,
db: Session,
x_tenant_domain: str | None = Header(default=None),
) -> Tenant:
raw_domain = x_tenant_domain or request.headers.get("host") or settings.dev_fallback_tenant_domain
domain = normalize_domain(raw_domain)
if domain in {"localhost", "127.0.0.1", "0.0.0.0"}:
domain = settings.dev_fallback_tenant_domain
mapping = db.query(TenantDomain).filter(TenantDomain.domain == domain).first()
if not mapping or not mapping.tenant or not mapping.tenant.is_active:
raise HTTPException(status_code=404, detail=f"No active tenant found for domain '{domain}'.")
return mapping.tenant
white-label-restaurant-saas/backend/app/tasks.py
from celery import Celery
from app.config import settings
celery_app = Celery(
"restaurant_saas",
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
)
@celery_app.task(name="orders.notify_restaurant")
def notify_restaurant(order_id: int) -> dict:
# In production, send an email, SMS, Slack message, or POS integration event here.
return {"order_id": order_id, "notification": "queued"}
white-label-restaurant-saas/backend/app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import Base, engine
from app.routers import health, media, menu_items, orders, payments, restaurants, tenants
Base.metadata.create_all(bind=engine)
app = FastAPI(title="White-Label Restaurant SaaS API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:8080"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(tenants.router, prefix="/api/tenants", tags=["tenants"])
app.include_router(restaurants.router, prefix="/api/restaurants", tags=["restaurants"])
app.include_router(menu_items.router, prefix="/api/menu-items", tags=["menu-items"])
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
app.include_router(media.router, prefix="/api/media", tags=["media"])
white-label-restaurant-saas/backend/app/routers/__init__.py
# Router package marker.
white-label-restaurant-saas/backend/app/routers/health.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health_check() -> dict[str, str]:
return {"status": "ok"}
white-label-restaurant-saas/backend/app/routers/tenants.py
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Restaurant, Tenant, TenantDomain
from app.schemas import TenantCreate, TenantRead
from app.tenant import get_tenant_from_request
router = APIRouter()
@router.post("", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
def create_tenant(payload: TenantCreate, db: Session = Depends(get_db)) -> Tenant:
tenant = Tenant(name=payload.name, slug=payload.slug)
db.add(tenant)
db.flush()
db.add(TenantDomain(tenant_id=tenant.id, domain=payload.domain.lower(), is_primary=True))
db.add(Restaurant(tenant_id=tenant.id, name=payload.name, description="", phone="", address=""))
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="Tenant slug or domain already exists.") from exc
db.refresh(tenant)
return tenant
@router.get("/current", response_model=TenantRead)
def read_current_tenant(
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> Tenant:
return get_tenant_from_request(request, db, x_tenant_domain)
white-label-restaurant-saas/backend/app/routers/restaurants.py
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Restaurant
from app.schemas import RestaurantRead, RestaurantUpdate
from app.tenant import get_tenant_from_request
router = APIRouter()
@router.get("/current", response_model=RestaurantRead)
def read_current_restaurant(
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> Restaurant:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
restaurant = db.query(Restaurant).filter(Restaurant.tenant_id == tenant.id).first()
if not restaurant:
raise HTTPException(status_code=404, detail="Restaurant profile not found.")
return restaurant
@router.put("/current", response_model=RestaurantRead)
def update_current_restaurant(
payload: RestaurantUpdate,
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> Restaurant:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
restaurant = db.query(Restaurant).filter(Restaurant.tenant_id == tenant.id).first()
if not restaurant:
raise HTTPException(status_code=404, detail="Restaurant profile not found.")
for field, value in payload.model_dump().items():
setattr(restaurant, field, value)
db.commit()
db.refresh(restaurant)
return restaurant
white-label-restaurant-saas/backend/app/routers/menu_items.py
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import MenuItem
from app.schemas import MenuItemCreate, MenuItemRead, MenuItemUpdate
from app.tenant import get_tenant_from_request
router = APIRouter()
@router.get("", response_model=list[MenuItemRead])
def list_menu_items(
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> list[MenuItem]:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
return (
db.query(MenuItem)
.filter(MenuItem.tenant_id == tenant.id)
.order_by(MenuItem.category.asc(), MenuItem.name.asc())
.all()
)
@router.post("", response_model=MenuItemRead, status_code=status.HTTP_201_CREATED)
def create_menu_item(
payload: MenuItemCreate,
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> MenuItem:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
item = MenuItem(tenant_id=tenant.id, **payload.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return item
@router.get("/{item_id}", response_model=MenuItemRead)
def read_menu_item(
item_id: int,
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> MenuItem:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
item = db.query(MenuItem).filter(MenuItem.id == item_id, MenuItem.tenant_id == tenant.id).first()
if not item:
raise HTTPException(status_code=404, detail="Menu item not found.")
return item
@router.put("/{item_id}", response_model=MenuItemRead)
def update_menu_item(
item_id: int,
payload: MenuItemUpdate,
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> MenuItem:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
item = db.query(MenuItem).filter(MenuItem.id == item_id, MenuItem.tenant_id == tenant.id).first()
if not item:
raise HTTPException(status_code=404, detail="Menu item not found.")
for field, value in payload.model_dump().items():
setattr(item, field, value)
db.commit()
db.refresh(item)
return item
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_menu_item(
item_id: int,
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> None:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
item = db.query(MenuItem).filter(MenuItem.id == item_id, MenuItem.tenant_id == tenant.id).first()
if not item:
raise HTTPException(status_code=404, detail="Menu item not found.")
db.delete(item)
db.commit()
white-label-restaurant-saas/backend/app/routers/orders.py
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.models import MenuItem, Order, OrderItem
from app.schemas import OrderCreate, OrderRead, OrderStatusUpdate
from app.tasks import notify_restaurant
from app.tenant import get_tenant_from_request
router = APIRouter()
@router.post("", response_model=OrderRead, status_code=status.HTTP_201_CREATED)
def create_order(
payload: OrderCreate,
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> Order:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
item_ids = [line.menu_item_id for line in payload.items]
menu_items = (
db.query(MenuItem)
.filter(MenuItem.tenant_id == tenant.id, MenuItem.id.in_(item_ids), MenuItem.is_available.is_(True))
.all()
)
menu_by_id = {item.id: item for item in menu_items}
total_cents = 0
order = Order(
tenant_id=tenant.id,
customer_name=payload.customer_name,
customer_phone=payload.customer_phone,
status="pending",
total_cents=0,
)
db.add(order)
db.flush()
for line in payload.items:
menu_item = menu_by_id.get(line.menu_item_id)
if not menu_item:
raise HTTPException(status_code=400, detail=f"Menu item {line.menu_item_id} is unavailable or invalid.")
total_cents += menu_item.price_cents * line.quantity
db.add(
OrderItem(
order_id=order.id,
menu_item_id=menu_item.id,
quantity=line.quantity,
unit_price_cents=menu_item.price_cents,
)
)
order.total_cents = total_cents
db.commit()
db.refresh(order)
notify_restaurant.delay(order.id)
return order
@router.get("", response_model=list[OrderRead])
def list_orders(
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> list[Order]:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
return (
db.query(Order)
.options(selectinload(Order.items))
.filter(Order.tenant_id == tenant.id)
.order_by(Order.id.desc())
.all()
)
@router.put("/{order_id}/status", response_model=OrderRead)
def update_order_status(
order_id: int,
payload: OrderStatusUpdate,
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> Order:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
order = (
db.query(Order)
.options(selectinload(Order.items))
.filter(Order.id == order_id, Order.tenant_id == tenant.id)
.first()
)
if not order:
raise HTTPException(status_code=404, detail="Order not found.")
order.status = payload.status
db.commit()
db.refresh(order)
return order
white-label-restaurant-saas/backend/app/routers/payments.py
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from sqlalchemy.orm import Session
import stripe
from app.config import settings
from app.database import get_db
from app.tenant import get_tenant_from_request
router = APIRouter()
@router.post("/connect/onboarding-link")
def create_connect_onboarding_link(
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> dict:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
if not settings.stripe_secret_key:
return {
"configured": False,
"message": "Stripe is not configured. Add STRIPE_SECRET_KEY to enable Connect onboarding.",
}
stripe.api_key = settings.stripe_secret_key
try:
if not tenant.stripe_account_id:
account = stripe.Account.create(type="express")
tenant.stripe_account_id = account["id"]
db.commit()
link = stripe.AccountLink.create(
account=tenant.stripe_account_id,
refresh_url=settings.stripe_connect_refresh_url,
return_url=settings.stripe_connect_return_url,
type="account_onboarding",
)
return {"configured": True, "url": link["url"]}
except stripe.StripeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
white-label-restaurant-saas/backend/app/routers/media.py
from uuid import uuid4
import boto3
from fastapi import APIRouter, Depends, Header, Request
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.schemas import PresignedUploadRequest
from app.tenant import get_tenant_from_request
router = APIRouter()
@router.post("/presigned-upload")
def create_presigned_upload_url(
payload: PresignedUploadRequest,
request: Request,
db: Session = Depends(get_db),
x_tenant_domain: str | None = Header(default=None),
) -> dict:
tenant = get_tenant_from_request(request, db, x_tenant_domain)
required = [
settings.spaces_endpoint_url,
settings.spaces_bucket,
settings.spaces_access_key_id,
settings.spaces_secret_access_key,
]
if not all(required):
return {
"configured": False,
"message": "DigitalOcean Spaces is not configured. Add Spaces credentials to enable uploads.",
}
extension = payload.filename.rsplit(".", 1)[-1].lower() if "." in payload.filename else "bin"
object_key = f"tenants/{tenant.slug}/uploads/{uuid4()}.{extension}"
client = boto3.client(
"s3",
region_name=settings.spaces_region,
endpoint_url=settings.spaces_endpoint_url,
aws_access_key_id=settings.spaces_access_key_id,
aws_secret_access_key=settings.spaces_secret_access_key,
)
upload_url = client.generate_presigned_url(
ClientMethod="put_object",
Params={
"Bucket": settings.spaces_bucket,
"Key": object_key,
"ContentType": payload.content_type,
},
ExpiresIn=900,
)
public_url = f"{settings.spaces_endpoint_url}/{settings.spaces_bucket}/{object_key}"
return {"configured": True, "upload_url": upload_url, "object_key": object_key, "public_url": public_url}
white-label-restaurant-saas/frontend/Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
EXPOSE 3000
white-label-restaurant-saas/frontend/package.json
{
"name": "white-label-restaurant-saas-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0 -p 3000",
"build": "next build",
"start": "next start -H 0.0.0.0 -p 3000"
},
"dependencies": {
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@types/node": "22.10.5",
"@types/react": "19.0.2",
"@types/react-dom": "19.0.2",
"typescript": "5.7.2"
}
}
white-label-restaurant-saas/frontend/next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true
};
module.exports = nextConfig;
white-label-restaurant-saas/frontend/tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
white-label-restaurant-saas/frontend/app/layout.tsx
import "./globals.css";
export const metadata = {
title: "White-Label Restaurant SaaS",
description: "Tenant-aware restaurant menu and ordering starter application"
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
white-label-restaurant-saas/frontend/app/page.tsx
import MenuManager from "./components/MenuManager";
export default function HomePage() {
return <MenuManager />;
}
white-label-restaurant-saas/frontend/app/components/MenuManager.tsx
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
type Tenant = {
id: number;
name: string;
slug: string;
stripe_account_id: string | null;
is_active: boolean;
};
type Restaurant = {
id: number;
name: string;
description: string;
phone: string;
address: string;
brand_color: string;
logo_url: string;
is_accepting_orders: boolean;
};
type MenuItem = {
id: number;
name: string;
description: string;
category: string;
price_cents: number;
image_url: string;
is_available: boolean;
};
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
function getTenantDomain(): string {
if (typeof window === "undefined") return "pizza.localhost";
const host = window.location.hostname;
if (host === "localhost" || host === "127.0.0.1") return "pizza.localhost";
return host;
}
function dollars(cents: number): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
}
export default function MenuManager() {
const [tenant, setTenant] = useState<Tenant | null>(null);
const [restaurant, setRestaurant] = useState<Restaurant | null>(null);
const [items, setItems] = useState<MenuItem[]>([]);
const [error, setError] = useState<string>("");
const [success, setSuccess] = useState<string>("");
const [form, setForm] = useState({
name: "",
description: "",
category: "Pizza",
price: "12.99",
image_url: "",
is_available: true
});
const tenantDomain = useMemo(getTenantDomain, []);
async function apiFetch(path: string, options: RequestInit = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
"X-Tenant-Domain": tenantDomain,
...(options.headers || {})
}
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.detail || `Request failed with status ${response.status}`);
}
if (response.status === 204) return null;
return response.json();
}
async function loadData() {
setError("");
try {
const [tenantData, restaurantData, menuData] = await Promise.all([
apiFetch("/api/tenants/current"),
apiFetch("/api/restaurants/current"),
apiFetch("/api/menu-items")
]);
setTenant(tenantData);
setRestaurant(restaurantData);
setItems(menuData);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load data.");
}
}
useEffect(() => {
loadData();
}, []);
async function createItem(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setSuccess("");
const priceCents = Math.round(Number(form.price) * 100);
if (!form.name.trim() || Number.isNaN(priceCents) || priceCents < 0) {
setError("Please enter a valid item name and non-negative price.");
return;
}
try {
await apiFetch("/api/menu-items", {
method: "POST",
body: JSON.stringify({
name: form.name,
description: form.description,
category: form.category,
price_cents: priceCents,
image_url: form.image_url,
is_available: form.is_available
})
});
setForm({ name: "", description: "", category: "Pizza", price: "12.99", image_url: "", is_available: true });
setSuccess("Menu item created.");
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to create menu item.");
}
}
async function toggleAvailability(item: MenuItem) {
setError("");
setSuccess("");
try {
await apiFetch(`/api/menu-items/${item.id}`, {
method: "PUT",
body: JSON.stringify({ ...item, is_available: !item.is_available })
});
setSuccess("Menu item updated.");
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to update item.");
}
}
async function deleteItem(item: MenuItem) {
setError("");
setSuccess("");
try {
await apiFetch(`/api/menu-items/${item.id}`, { method: "DELETE" });
setSuccess("Menu item deleted.");
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to delete item.");
}
}
async function startStripeOnboarding() {
setError("");
setSuccess("");
try {
const result = await apiFetch("/api/payments/connect/onboarding-link", { method: "POST", body: JSON.stringify({}) });
if (!result.configured) {
setSuccess(result.message);
return;
}
window.location.href = result.url;
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to start onboarding.");
}
}
return (
<main className="page">
<section className="hero" style={{ borderTopColor: restaurant?.brand_color || "#b91c1c" }}>
<p className="eyebrow">White-label tenant domain: {tenantDomain}</p>
<h1>{restaurant?.name || tenant?.name || "Restaurant SaaS"}</h1>
<p>{restaurant?.description || "Manage a tenant-specific menu from one shared SaaS platform."}</p>
{restaurant && (
<div className="meta">
<span>{restaurant.phone}</span>
<span>{restaurant.address}</span>
<span>{restaurant.is_accepting_orders ? "Accepting orders" : "Orders paused"}</span>
</div>
)}
<button onClick={startStripeOnboarding}>Start Stripe Connect Onboarding</button>
</section>
{error && <div className="alert error">{error}</div>}
{success && <div className="alert success">{success}</div>}
<section className="grid">
<form className="card" onSubmit={createItem}>
<h2>Create Menu Item</h2>
<label>
Name
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Mushroom Truffle Pizza" />
</label>
<label>
Description
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Describe the dish" />
</label>
<label>
Category
<input value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} />
</label>
<label>
Price
<input value={form.price} onChange={(e) => setForm({ ...form, price: e.target.value })} />
</label>
<label>
Image URL
<input value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
</label>
<label className="checkbox">
<input type="checkbox" checked={form.is_available} onChange={(e) => setForm({ ...form, is_available: e.target.checked })} />
Available
</label>
<button type="submit">Create Item</button>
</form>
<section className="card">
<h2>Menu Items</h2>
<div className="items">
{items.map((item) => (
<article key={item.id} className="item">
<div>
<p className="category">{item.category}</p>
<h3>{item.name}</h3>
<p>{item.description}</p>
<strong>{dollars(item.price_cents)}</strong>
<p className={item.is_available ? "available" : "unavailable"}>
{item.is_available ? "Available" : "Unavailable"}
</p>
</div>
<div className="actions">
<button type="button" onClick={() => toggleAvailability(item)}>
{item.is_available ? "Mark unavailable" : "Mark available"}
</button>
<button type="button" className="danger" onClick={() => deleteItem(item)}>
Delete
</button>
</div>
</article>
))}
</div>
</section>
</section>
</main>
);
}
white-label-restaurant-saas/frontend/app/globals.css
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f8fafc;
color: #0f172a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
textarea {
font: inherit;
}
button {
border: 0;
border-radius: 10px;
padding: 0.75rem 1rem;
background: #0f172a;
color: white;
cursor: pointer;
}
button:hover {
opacity: 0.9;
}
button.danger {
background: #b91c1c;
}
.page {
width: min(1120px, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0;
}
.hero,
.card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 20px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.hero {
border-top: 8px solid #b91c1c;
padding: 2rem;
margin-bottom: 1.5rem;
}
.hero h1 {
margin: 0.25rem 0;
font-size: clamp(2rem, 5vw, 4rem);
}
.eyebrow,
.category {
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.75rem;
font-weight: 700;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1rem 0;
color: #334155;
}
.grid {
display: grid;
grid-template-columns: 380px 1fr;
gap: 1.5rem;
align-items: start;
}
.card {
padding: 1.25rem;
}
label {
display: grid;
gap: 0.35rem;
margin-bottom: 1rem;
color: #334155;
font-weight: 700;
}
input,
textarea {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 0.75rem;
}
textarea {
min-height: 90px;
}
.checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
}
.checkbox input {
width: auto;
}
.alert {
margin-bottom: 1rem;
border-radius: 12px;
padding: 1rem;
font-weight: 700;
}
.alert.error {
background: #fee2e2;
color: #991b1b;
}
.alert.success {
background: #dcfce7;
color: #166534;
}
.items {
display: grid;
gap: 1rem;
}
.item {
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 1rem;
display: flex;
justify-content: space-between;
gap: 1rem;
}
.item h3 {
margin: 0;
}
.actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 160px;
}
.available {
color: #166534;
font-weight: 700;
}
.unavailable {
color: #991b1b;
font-weight: 700;
}
@media (max-width: 860px) {
.grid,
.item {
grid-template-columns: 1fr;
display: grid;
}
}
11. Setup and Installation
Prerequisites
Install:
- Docker Desktop or Docker Engine with Docker Compose
- Git, if cloning from a repository
- Optional: Node.js 22 and Python 3.12 for running outside Docker
The easiest path is Docker Compose because it avoids installing PostgreSQL, Redis, Python dependencies, and Node dependencies manually.
Local Setup
mkdir white-label-restaurant-saas
cd white-label-restaurant-saas
Create all files shown in the source-code section.
Then create your environment file:
cp .env.example .env
Start the stack:
docker compose up --build
Initialize the database:
docker compose exec backend python seed.py
Open the frontend:
http://localhost:3000
Open the API docs:
http://localhost:8000/docs
Open through Nginx:
http://localhost:8080
Environment Variables
| Variable | Required for Core App | Purpose |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string used by FastAPI. |
REDIS_URL | Yes | Redis connection for caching patterns. |
CELERY_BROKER_URL | Yes | Celery broker URL. |
CELERY_RESULT_BACKEND | Yes | Celery result backend URL. |
DEV_FALLBACK_TENANT_DOMAIN | Yes | Tenant domain used when testing on localhost. |
STRIPE_SECRET_KEY | No | Enables real Stripe Connect onboarding. |
STRIPE_CONNECT_RETURN_URL | No | Redirect after onboarding completion. |
STRIPE_CONNECT_REFRESH_URL | No | Redirect when onboarding link expires. |
SPACES_ENDPOINT_URL | No | DigitalOcean Spaces S3 endpoint. |
SPACES_BUCKET | No | Target media bucket. |
SPACES_ACCESS_KEY_ID | No | Spaces access key. |
SPACES_SECRET_ACCESS_KEY | No | Spaces secret key. |
Safe Development Notes
- The included CORS settings are for local development.
- The included tenant resolution is intentionally simple.
- The app does not include user authentication yet.
- Stripe and Spaces endpoints are structured but optional until credentials are configured.
- Do not commit real
.envsecrets to Git.
12. Testing Instructions
Manual Test Checklist
| Test | Steps | Expected Outcome |
|---|---|---|
| Health check | Open http://localhost:8000/health | Response shows { "status": "ok" }. |
| Tenant resolution | Run curl -H "X-Tenant-Domain: pizza.localhost" http://localhost:8000/api/tenants/current | Response returns Luigi's Pizza tenant. |
| Read menu items | Run curl -H "X-Tenant-Domain: sushi.localhost" http://localhost:8000/api/menu-items | Response returns Sakura Sushi menu items. |
| Create menu item | Use the frontend form to create a new item. | New item appears in the menu list. |
| Validate incomplete input | Submit the form without an item name. | Frontend shows a validation message. |
| Update item | Click Mark unavailable or Mark available. | Item availability changes and persists. |
| Delete item | Click Delete. | Item disappears from the menu list. |
| Confirm persistence | Restart with docker compose restart, then reload the page. | Previously created database records remain. |
| Create order | Use API docs or curl to post to /api/orders. | Order is created with calculated total. |
| Stripe configuration check | Click Start Stripe Connect Onboarding without a Stripe key. | App shows a friendly configuration message. |
| Spaces configuration check | Post to /api/media/presigned-upload without Spaces credentials. | API returns a friendly configuration message. |
Example Order Test
curl -X POST http://localhost:8000/api/orders \
-H "Content-Type: application/json" \
-H "X-Tenant-Domain: pizza.localhost" \
-d '{
"customer_name": "Maya Rivera",
"customer_phone": "555-991-0101",
"items": [
{ "menu_item_id": 1, "quantity": 2 }
]
}'
Expected result: the API returns a new order with status set to pending and a calculated total_cents.
13. Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
ModuleNotFoundError in backend | Dependencies were not installed or image was not rebuilt. | Run docker compose build backend and restart. |
| Frontend cannot reach API | NEXT_PUBLIC_API_BASE_URL is wrong or backend is down. | Confirm .env value and open http://localhost:8000/health. |
No active tenant found for domain | Missing X-Tenant-Domain header or seed data not loaded. | Run docker compose exec backend python seed.py and include X-Tenant-Domain: pizza.localhost. |
| PostgreSQL connection error | Database container is not ready or credentials mismatch. | Check docker compose ps, .env, and docker compose logs db. |
| Missing tables | Schema was not created yet. | Restart backend or run docker compose exec backend python seed.py. |
| Port already in use | Another service is using 3000, 8000, 8080, 5432, or 6379. | Stop the other service or change the Compose port mapping. |
| Static CSS not loading | Frontend container did not rebuild after file changes. | Restart frontend with docker compose restart frontend. |
| Nginx returns bad gateway | Frontend or backend container is not running. | Run docker compose ps and inspect logs. |
| Foreign-key failure | An order references a menu item from another tenant or a missing item. | Use tenant-scoped item IDs from /api/menu-items. |
| Delete fails for ordered item | Production-style restrict behavior can prevent deleting referenced items. | Use soft delete or set is_available=false instead. |
| Stripe onboarding returns configuration message | STRIPE_SECRET_KEY is empty. | Add a valid Stripe secret key in .env for real onboarding. |
| Spaces upload returns configuration message | Spaces credentials are empty. | Add endpoint, bucket, region, and access keys in .env. |
| Data not appearing after form submission | Validation failed, tenant header is wrong, or API request failed. | Check browser console, API logs, and the frontend alert message. |
14. Production and Security Guidance
This project is appropriate for learning, demonstrations, prototypes, and internal proof-of-concept work. It is not complete enough for a public production SaaS without additional security, operational, and compliance work.
Before deploying publicly, upgrade the platform in the following areas.
Database and Migrations
- Use a managed PostgreSQL database or a properly backed-up production PostgreSQL instance.
- Add Alembic migrations instead of relying on
Base.metadata.create_all. - Add restore-tested automated backups.
- Consider tenant-level backup/export needs.
- Add soft deletes for menu items and restaurants.
- Add audit tables for sensitive administrative changes.
Authentication and Authorization
- Add user accounts for platform admins and restaurant managers.
- Enforce tenant-scoped authorization on every endpoint.
- Use role-based access control such as
platform_admin,tenant_owner, andrestaurant_manager. - Add secure password hashing or external identity provider login.
- Add MFA for platform and tenant admins.
Tenant Isolation
- Keep tenant lookup centralized.
- Never trust tenant IDs from the browser without checking authorization.
- Add automated tests proving one tenant cannot access another tenant's data.
- Consider row-level security in PostgreSQL for stronger tenant isolation.
Stripe Connect
- Use Stripe webhooks to track onboarding status, payment events, refunds, and disputes.
- Store only Stripe IDs, not raw card or bank data.
- Validate webhook signatures.
- Decide whether your platform uses destination charges, separate charges and transfers, or another Connect flow.
- Add clear onboarding and payout-status screens for restaurant tenants.
DigitalOcean Spaces and Media Security
- Validate file types and file sizes before issuing upload URLs.
- Store uploaded media records in the database.
- Use tenant-specific object prefixes.
- Consider image resizing, malware scanning, and moderation.
- Use CDN caching rules carefully for public assets.
Web Security
- Serve the application over HTTPS.
- Use secure cookies when adding authentication.
- Add CSRF protection for cookie-authenticated browser actions.
- Add rate limiting for login, checkout, order creation, and upload endpoints.
- Lock down CORS to known production domains.
- Validate all server-side input even if the frontend already validates it.
- Avoid leaking stack traces or internal errors to public users.
Operations
- Add structured logging.
- Add error monitoring.
- Add request metrics and uptime checks.
- Add container health checks for every service.
- Use a production ASGI server configuration without reload mode.
- Use CI/CD for tests, builds, and deployments.
- Pin and update dependencies regularly.
- Run vulnerability scans on images and dependencies.
Nginx and Custom Domains
- Automate domain onboarding and verification.
- Use DNS validation before attaching a custom domain to a tenant.
- Automate TLS certificates with a tool such as Certbot or a managed load balancer.
- Prevent domain takeover by requiring proof that the restaurant controls the domain.
- Preserve the original
Hostheader so tenant resolution remains accurate.
Background Jobs
- Add retry policies and dead-letter handling for critical tasks.
- Keep background tasks idempotent.
- Store job outcomes when they affect customer or restaurant workflows.
- Separate urgent queues, such as order notifications, from slow queues, such as reporting.
15. Conclusion
You built a complete starter architecture for a white-label multi-tenant restaurant commerce SaaS platform. The project demonstrates how one shared application can support many restaurant brands, domains, menus, and customer experiences while keeping data scoped by tenant.
The key concepts covered were:
- White-label SaaS architecture
- Shared-schema multi-tenancy
- Custom-domain tenant resolution
- FastAPI route design
- Next.js frontend integration
- PostgreSQL relational modeling
- Menu item CRUD
- Basic order creation
- Redis and Celery background-job setup
- Nginx reverse proxy routing
- Stripe Connect onboarding structure
- DigitalOcean Spaces-compatible media upload structure
- Docker Compose local development
Practical next enhancements include:
- Tenant admin authentication
- Restaurant manager roles
- Full checkout with Stripe Connect payment intents
- Stripe webhook processing
- Menu categories and modifiers
- Cart UI and customer checkout page
- Reservation management
- Coupon and promotion management
- Media library database table
- Image resizing background jobs
- Order email or SMS notifications
- PostgreSQL row-level security
- Automated tests
- Alembic migrations
- Production Docker deployment
- Custom-domain verification workflow
This starter project gives you the foundation for a real SaaS product: one platform, many restaurant brands, and a scalable path from local prototype to production-ready white-label commerce system.