Ozzie GhaniPortfolio

SaaS Architecture

White-Label Multi-Tenant Commerce SaaS

A production-minded technical walkthrough for building a white-label restaurant commerce platform that lets restaurants manage branded menus, pricing, ordering, payments, media assets, and custom domains while your SaaS operates the shared infrastructure.

All writing

July 8, 2026

SaaS WebApp Cover

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 Host header
  • 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:

  1. A restaurant subscribes to the platform.
  2. The platform creates a tenant record and associates one or more domains with it.
  3. The restaurant creates or updates its business profile.
  4. The restaurant adds menu items, prices, descriptions, categories, images, and availability.
  5. The restaurant connects a Stripe account for payouts.
  6. Customers visit the restaurant's own domain.
  7. Customers browse the menu and place orders.
  8. 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

FeatureDescription
Tenant resolutionMaps a request domain to a tenant record.
Restaurant profileStores each tenant's restaurant name, brand colors, contact information, and status.
Menu managementAllows each tenant to create, view, update, and delete menu items.
OrderingAllows customers to submit basic orders against menu items.
Order status updatesAllows restaurant staff or the API to move orders through statuses such as pending, accepted, preparing, ready, completed, or cancelled.
Stripe Connect onboardingProvides an endpoint where a tenant can start payment-account onboarding.
Media upload helperProvides an endpoint structure for creating presigned upload URLs for S3-compatible storage such as DigitalOcean Spaces.
Background jobsUses Celery and Redis for asynchronous work such as order notifications or reporting.
Nginx routingPreserves 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.

TechnologyRoleWhy It Was Selected
PythonBackend programming languagePython is readable, widely adopted, and well suited for API development and background jobs.
FastAPIAPI backendFastAPI provides typed request/response validation, automatic OpenAPI documentation, and strong ergonomics for API-first systems.
Next.jsFrontend applicationNext.js supports modern React application structure and can serve branded tenant-facing pages.
PostgreSQLPrimary databasePostgreSQL is a reliable relational database for multi-tenant SaaS data, constraints, indexes, and transactions.
RedisCache and Celery brokerRedis is useful for fast tenant lookups, caching, queues, and background job coordination.
CeleryBackground jobsCelery processes asynchronous tasks such as notifications, reports, media processing, and integrations.
NginxReverse proxy and routingNginx receives HTTP traffic, serves as an entry point, and forwards requests while preserving the original host.
Docker ComposeLocal orchestrationDocker Compose runs the API, frontend, database, cache, worker, and proxy together for local development.
Stripe ConnectTenant payment onboardingStripe Connect allows the platform to connect restaurant accounts and route payment flows to tenant accounts.
DigitalOcean SpacesMedia/object storageSpaces provides S3-compatible object storage for menu images, logos, and media assets.
SQLAlchemyDatabase ORMSQLAlchemy maps Python models to PostgreSQL tables and keeps queries maintainable.
PydanticData validationPydantic 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

LayerResponsibility
Browser / UIDisplays the restaurant storefront, menu forms, order form, and tenant-specific branding.
NginxAccepts inbound HTTP traffic and forwards requests to the frontend or API while preserving the original Host header.
Next.jsRenders the user interface and sends tenant-aware API requests to FastAPI.
FastAPIValidates requests, resolves the tenant, runs business logic, and returns JSON responses.
API endpointsProvide REST-style operations for tenants, restaurants, menu items, orders, media, and payments.
Database layerUses SQLAlchemy models and sessions to query PostgreSQL safely.
PostgreSQLStores tenants, domains, restaurant profiles, menu items, and orders.
RedisSupports caching and Celery message brokering.
CeleryRuns slow or asynchronous tasks outside the request/response cycle.
Stripe ConnectHandles tenant payment-account onboarding and future checkout routing.
DigitalOcean SpacesStores restaurant logos, menu images, and uploaded media assets.

Request Flow

  1. A customer opens pizza.localhost or a real restaurant domain.
  2. Nginx forwards the request to the Next.js frontend.
  3. Next.js reads the current host and calls the FastAPI backend.
  4. The API receives the tenant domain through X-Tenant-Domain.
  5. FastAPI looks up the matching tenant in PostgreSQL.
  6. The backend filters all restaurant, menu, and order operations by tenant_id.
  7. The API returns tenant-scoped data.
  8. 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

TablePurpose
tenantsStores each subscribed business tenant.
tenant_domainsMaps custom domains or subdomains to tenants.
restaurantsStores restaurant profile and branding details for each tenant.
menu_itemsStores tenant-specific menu items, descriptions, prices, categories, and images.
ordersStores customer orders.
order_itemsStores individual menu items inside each order.

Column Documentation

TableColumnData TypePrimary KeyForeign KeyDefaultConstraintPurpose
tenantsidSERIALYesNoAutoNot nullUnique tenant identifier.
tenantsnameVARCHAR(120)NoNoNoneNot nullTenant business name.
tenantsslugVARCHAR(80)NoNoNoneUnique, not nullURL-safe tenant identifier.
tenantsstripe_account_idVARCHAR(120)NoNoNullNoneStripe connected account ID.
tenantsis_activeBOOLEANNoNoTRUENot nullControls whether tenant is active.
tenantscreated_atTIMESTAMPTZNoNoNOW()Not nullCreation timestamp.
tenant_domainsidSERIALYesNoAutoNot nullUnique domain mapping ID.
tenant_domainstenant_idINTEGERNotenants.idNoneCascade deleteOwner tenant.
tenant_domainsdomainVARCHAR(255)NoNoNoneUnique, not nullCustom domain or local test host.
tenant_domainsis_primaryBOOLEANNoNoFALSENot nullIdentifies preferred domain.
restaurantsidSERIALYesNoAutoNot nullRestaurant profile ID.
restaurantstenant_idINTEGERNotenants.idNoneUnique, cascade deleteTenant owner.
restaurantsnameVARCHAR(160)NoNoNoneNot nullCustomer-facing restaurant name.
restaurantsdescriptionTEXTNoNoEmpty stringNot nullPublic restaurant description.
restaurantsphoneVARCHAR(40)NoNoEmpty stringNot nullContact phone.
restaurantsaddressTEXTNoNoEmpty stringNot nullPublic address.
restaurantsbrand_colorVARCHAR(20)NoNo#b91c1cNot nullMain storefront color.
restaurantslogo_urlTEXTNoNoEmpty stringNot nullLogo image URL.
restaurantsis_accepting_ordersBOOLEANNoNoTRUENot nullControls customer ordering.
menu_itemsidSERIALYesNoAutoNot nullMenu item ID.
menu_itemstenant_idINTEGERNotenants.idNoneCascade deleteTenant owner.
menu_itemsnameVARCHAR(160)NoNoNoneNot nullMenu item name.
menu_itemsdescriptionTEXTNoNoEmpty stringNot nullMenu item description.
menu_itemscategoryVARCHAR(80)NoNoGeneralNot nullMenu category.
menu_itemsprice_centsINTEGERNoNoNoneprice_cents >= 0Price stored in cents.
menu_itemsimage_urlTEXTNoNoEmpty stringNot nullMenu item image URL.
menu_itemsis_availableBOOLEANNoNoTRUENot nullControls visibility/orderability.
menu_itemscreated_atTIMESTAMPTZNoNoNOW()Not nullCreation timestamp.
ordersidSERIALYesNoAutoNot nullOrder ID.
orderstenant_idINTEGERNotenants.idNoneCascade deleteTenant owner.
orderscustomer_nameVARCHAR(120)NoNoNoneNot nullCustomer name.
orderscustomer_phoneVARCHAR(40)NoNoEmpty stringNot nullCustomer phone.
ordersstatusVARCHAR(40)NoNopendingStatus checkOrder workflow status.
orderstotal_centsINTEGERNoNo0total_cents >= 0Calculated order total.
orderscreated_atTIMESTAMPTZNoNoNOW()Not nullOrder timestamp.
order_itemsidSERIALYesNoAutoNot nullOrder line item ID.
order_itemsorder_idINTEGERNoorders.idNoneCascade deleteParent order.
order_itemsmenu_item_idINTEGERNomenu_items.idNoneRestrict deleteOrdered menu item.
order_itemsquantityINTEGERNoNoNonequantity > 0Quantity ordered.
order_itemsunit_price_centsINTEGERNoNoNoneunit_price_cents >= 0Price 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 ConstraintReason
tenant_domains.domain UNIQUEPrevents two tenants from claiming the same domain.
tenants.slug UNIQUEProvides a stable tenant identifier.
restaurants.tenant_id UNIQUEKeeps this starter project to one restaurant profile per tenant.
menu_items.price_cents >= 0Prevents negative prices.
orders.status CHECKPrevents unsupported order workflow states.
idx_tenant_domains_domainSpeeds up tenant lookup by domain.
idx_menu_items_tenant_categorySpeeds up tenant-scoped menu filtering.
idx_orders_tenant_status_createdSpeeds up tenant-scoped order dashboards.

8. Route Map / API Design

MethodPathPurposeRequired InputExpected Response
GET/healthHealth checkNone{ "status": "ok" }
POST/api/tenantsCreate tenantJSON: name, slug, domainTenant object
GET/api/tenants/currentResolve current tenant by domainX-Tenant-Domain headerTenant object
GET/api/restaurants/currentRead tenant restaurant profileX-Tenant-Domain headerRestaurant object
PUT/api/restaurants/currentUpdate tenant restaurant profileHeader plus JSON restaurant fieldsUpdated restaurant object
GET/api/menu-itemsList tenant menu itemsX-Tenant-Domain headerList of menu items
POST/api/menu-itemsCreate menu itemHeader plus JSON: name, description, category, price_cents, image_url, is_availableCreated menu item
GET/api/menu-items/{item_id}Read one menu itemHeader plus path IDMenu item object
PUT/api/menu-items/{item_id}Update menu itemHeader, path ID, JSON fieldsUpdated menu item
DELETE/api/menu-items/{item_id}Delete menu itemHeader plus path ID204 No Content
POST/api/ordersCreate customer orderHeader plus JSON customer and line itemsCreated order summary
GET/api/ordersList tenant ordersHeaderList of orders
PUT/api/orders/{order_id}/statusUpdate order statusHeader, path ID, JSON: statusUpdated order
POST/api/payments/connect/onboarding-linkCreate Stripe onboarding linkHeaderURL response or configuration message
POST/api/media/presigned-uploadCreate Spaces upload URLHeader, JSON: filename, content_typeUpload 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

VariableRequired for Core AppPurpose
DATABASE_URLYesPostgreSQL connection string used by FastAPI.
REDIS_URLYesRedis connection for caching patterns.
CELERY_BROKER_URLYesCelery broker URL.
CELERY_RESULT_BACKENDYesCelery result backend URL.
DEV_FALLBACK_TENANT_DOMAINYesTenant domain used when testing on localhost.
STRIPE_SECRET_KEYNoEnables real Stripe Connect onboarding.
STRIPE_CONNECT_RETURN_URLNoRedirect after onboarding completion.
STRIPE_CONNECT_REFRESH_URLNoRedirect when onboarding link expires.
SPACES_ENDPOINT_URLNoDigitalOcean Spaces S3 endpoint.
SPACES_BUCKETNoTarget media bucket.
SPACES_ACCESS_KEY_IDNoSpaces access key.
SPACES_SECRET_ACCESS_KEYNoSpaces 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 .env secrets to Git.

12. Testing Instructions

Manual Test Checklist

TestStepsExpected Outcome
Health checkOpen http://localhost:8000/healthResponse shows { "status": "ok" }.
Tenant resolutionRun curl -H "X-Tenant-Domain: pizza.localhost" http://localhost:8000/api/tenants/currentResponse returns Luigi's Pizza tenant.
Read menu itemsRun curl -H "X-Tenant-Domain: sushi.localhost" http://localhost:8000/api/menu-itemsResponse returns Sakura Sushi menu items.
Create menu itemUse the frontend form to create a new item.New item appears in the menu list.
Validate incomplete inputSubmit the form without an item name.Frontend shows a validation message.
Update itemClick Mark unavailable or Mark available.Item availability changes and persists.
Delete itemClick Delete.Item disappears from the menu list.
Confirm persistenceRestart with docker compose restart, then reload the page.Previously created database records remain.
Create orderUse API docs or curl to post to /api/orders.Order is created with calculated total.
Stripe configuration checkClick Start Stripe Connect Onboarding without a Stripe key.App shows a friendly configuration message.
Spaces configuration checkPost 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

SymptomLikely CauseFix
ModuleNotFoundError in backendDependencies were not installed or image was not rebuilt.Run docker compose build backend and restart.
Frontend cannot reach APINEXT_PUBLIC_API_BASE_URL is wrong or backend is down.Confirm .env value and open http://localhost:8000/health.
No active tenant found for domainMissing 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 errorDatabase container is not ready or credentials mismatch.Check docker compose ps, .env, and docker compose logs db.
Missing tablesSchema was not created yet.Restart backend or run docker compose exec backend python seed.py.
Port already in useAnother service is using 3000, 8000, 8080, 5432, or 6379.Stop the other service or change the Compose port mapping.
Static CSS not loadingFrontend container did not rebuild after file changes.Restart frontend with docker compose restart frontend.
Nginx returns bad gatewayFrontend or backend container is not running.Run docker compose ps and inspect logs.
Foreign-key failureAn 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 itemProduction-style restrict behavior can prevent deleting referenced items.Use soft delete or set is_available=false instead.
Stripe onboarding returns configuration messageSTRIPE_SECRET_KEY is empty.Add a valid Stripe secret key in .env for real onboarding.
Spaces upload returns configuration messageSpaces credentials are empty.Add endpoint, bucket, region, and access keys in .env.
Data not appearing after form submissionValidation 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, and restaurant_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 Host header 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.

Build a White-Label Restaurant SaaS with FastAPI, Next.js, PostgreSQL, and Stripe Connect