Files
prompts/docs/skills/nicegui/references/fastapi-uvicorn-startup.md
T
2026-07-30 20:51:16 -05:00

14 KiB

FastAPI And Uvicorn Startup

Use this reference when FastAPI owns the application and NiceGUI is one part of it. The central distinction is between composing an ASGI application and starting an ASGI server:

  • ui.run_with() composes NiceGUI with a caller-owned FastAPI application. It does not start Uvicorn.
  • uvicorn.run() starts the server and tells it which ASGI application to serve.

Ownership Model

flowchart TD
    E["Project script: my-app"] --> M["main()"]
    M --> S["get_settings()"]
    M --> U["uvicorn.run()"]
    U --> F["create_app()"]
    F --> S
    F --> P["Parent FastAPI app"]
    P --> A["API routes and middleware"]
    P -->|"mount_path=/gui"| N["NiceGUI App"]
    U -->|"ASGI requests and lifespan"| P

The objects have separate responsibilities:

Object Owner Responsibility
Parent FastAPI instance Application code Root ASGI app, API routes, middleware, lifespan, and mounted applications
Settings instance Application code Immutable, process-local configuration snapshot shared by startup and composition
nicegui.app NiceGUI A process-local App instance that subclasses FastAPI
ui.run_with(parent_app) NiceGUI integration Configures NiceGUI, mounts nicegui.app into parent_app, and integrates lifecycle handling
Uvicorn Server process Imports or receives the root ASGI app, opens sockets, drives lifespan, and serves requests

Uvicorn must serve the parent FastAPI app when using ui.run_with(). Passing nicegui.app to ui.run_with() is rejected because it would mount NiceGUI into itself and recurse on unmatched routes.

Choose One Startup Mode

Let NiceGUI Own Startup

Use ui.run() when NiceGUI is the main application. Add ordinary FastAPI routes to the exported nicegui.app object:

from nicegui import app, ui


@app.get('/healthz')
def health() -> dict[str, str]:
    return {'status': 'ok'}


@ui.page('/')
def home() -> None:
    ui.label('Home')


ui.run()

In this mode, NiceGUI configures and starts its own Uvicorn-derived server. Do not also call uvicorn.run().

Let FastAPI Own The Application

Use ui.run_with() when an existing FastAPI application owns middleware, API routers, OpenAPI configuration, lifespan resources, or deployment startup. The official NiceGUI FastAPI example follows this model.

mount_path controls where the NiceGUI application appears externally. A NiceGUI page declared as / is reachable at /gui/ when mounted at /gui, while parent routes such as /healthz remain at the root. A dedicated UI prefix usually makes ownership and route conflicts clearer than mounting both applications at /.

Canonical Factory Layout

Keep application composition importable and server startup explicit:

.
├─ pyproject.toml
└─ src/
   └─ my_app/
      ├─ __init__.py
      ├─ config.py
      └─ main.py
from functools import cache
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class ServerSettings(BaseModel):
    model_config = ConfigDict(frozen=True)

    host: str = '0.0.0.0'
    port: int = 8000
    log_level: Literal['critical', 'error', 'warning', 'info', 'debug', 'trace'] = (
        'info'
    )
    reload: bool = False


class GuiSettings(BaseModel):
    model_config = ConfigDict(frozen=True)

    mount_path: str = '/gui'
    storage_secret: SecretStr | None = None


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix='MY_APP_',
        env_nested_delimiter='__',
        env_file='.env',
        env_file_encoding='utf-8',
        frozen=True,
    )

    server: ServerSettings = Field(default_factory=ServerSettings)
    gui: GuiSettings = Field(default_factory=GuiSettings)


@cache
def get_settings() -> Settings:
    return Settings()

ServerSettings and GuiSettings inherit from BaseModel because they share one application owner, source policy, and process lifecycle. The root BaseSettings reads the sources once and validates one atomic snapshot. Environment variables use names such as MY_APP_SERVER__PORT, MY_APP_SERVER__RELOAD, MY_APP_GUI__MOUNT_PATH, and MY_APP_GUI__STORAGE_SECRET.

The argument-free functools.cache provider is appropriate here because both the project entry point and Uvicorn's zero-argument factory need process-lifetime access. Each reload or worker process gets its own settings instance. Do not add override arguments to get_settings(); inject a Settings instance directly into create_app() in tests or alternate composition roots. See the Pydantic settings implementation guide for source precedence, independent settings boundaries, cache clearing, and runtime reload guidance.

from collections.abc import AsyncGeneratorr
from contextlib import asynccontextmanager

import uvicorn
from fastapi import FastAPI
from nicegui import ui

from my_app.config import Settings, get_settings


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
    app.state.ready = True
    try:
        yield
    finally:
        app.state.ready = False


def register_pages() -> None:
    @ui.page('/')
    def dashboard() -> None:
        ui.label('Dashboard')


def create_app(settings: Settings | None = None) -> FastAPI:
    settings = settings or get_settings()
    app = FastAPI(lifespan=lifespan)
    app.state.settings = settings

    @app.get('/healthz')
    def health() -> dict[str, str]:
        return {'status': 'ok'}

    register_pages()
    ui.run_with(
        app,
        mount_path=settings.gui.mount_path,
        storage_secret=(
            settings.gui.storage_secret.get_secret_value()
            if settings.gui.storage_secret is not None
            else None
        ),
    )
    return app


def main() -> None:
    settings = get_settings()
    uvicorn.run(
        'my_app.main:create_app',
        factory=True,
        host=settings.server.host,
        port=settings.server.port,
        log_level=settings.server.log_level,
        reload=settings.server.reload,
    )


if __name__ == '__main__':
    main()

The storage_secret is optional unless the application uses ui.storage.user or ui.storage.browser. SecretStr prevents accidental plaintext display in logs and model representations, while get_secret_value() unwraps it only at the NiceGUI integration boundary. Supply production secrets through environment variables or a supported settings secret source rather than committing them.

The example passes an import string and factory=True to Uvicorn. Uvicorn imports my_app.main, calls the zero-argument create_app factory, and serves the returned parent FastAPI app. Import strings are also required when Uvicorn creates reload or worker subprocesses; passing create_app() directly only supports the simple single-process case.

NiceGUI keeps framework state in its process-local app singleton. Treat create_app() as a once-per-worker factory. Calling it repeatedly in one interpreter can register the same pages and lifecycle handlers more than once; tests that create multiple apps must isolate or reset NiceGUI state.

Lifespan Ordering

The ASGI lifespan protocol is driven by the server. Uvicorn sends startup before accepting requests and sends shutdown while terminating the process. Lifespan runs once per event loop, including once in each worker process.

Current NiceGUI source integrates with the parent application by:

  1. Capturing the parent FastAPI lifespan context.
  2. Mounting NiceGUI's internal app on the parent.
  3. Replacing the parent lifespan with a wrapper.
  4. Starting NiceGUI before entering the original parent lifespan.
  5. Exiting the original parent lifespan before shutting down NiceGUI.

This exact ordering comes from the current ui.run_with implementation and is version-sensitive. Check the pinned NiceGUI version before making one startup handler depend on another framework's internal ordering.

Create database pools, HTTP clients, and similar resources in the parent FastAPI lifespan, then close them after yield. Do not create event-loop-bound resources at import time or assume that globals are shared between workers.

Expose The Server As A Project Script

Map a command name to the no-argument startup function:

[project]
name = "my-app"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "fastapi",
    "nicegui",
    "pydantic-settings",
    "uvicorn[standard]",
]

[project.scripts]
my-app = "my_app.main:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/my_app"]

Run the installed command through uv:

uv run my-app

The uv project entry-point documentation requires a build system so uv installs the project and generates its command. The [project.scripts] target follows the PyPA entry-point specification: its generated wrapper imports main, calls it without arguments, and uses the return value as the process exit status. Returning None means successful completion.

The settings model now owns host, port, logging, reload, mount path, and storage-secret configuration. Add an explicit CLI settings source or another CLI parser only when the project command needs user-supplied arguments; the entry-point callable itself still receives no arguments.

Development Reload

Because main() supplies an import string, it can enable Uvicorn reload for local development:

MY_APP_SERVER__HOST=127.0.0.1
MY_APP_SERVER__RELOAD=true

The cached settings object is a process-start snapshot. Changing an environment variable or dotenv file does not mutate a running instance; restart the process, or let the development reloader create a new worker when a watched file changes. Keep reload disabled in production. Uvicorn documents reload and workers as mutually exclusive, and each worker would have independent settings, NiceGUI state, lifespan resources, and WebSocket connections. Use one worker by default unless the application has explicitly validated session affinity and externalized every stateful dependency needed across processes.

Anti-Patterns

Anti-pattern Why it fails Preferred approach
ui.run_with(nicegui.app) Mounts NiceGUI into itself Pass a separately created FastAPI() instance
Calling both ui.run() and ui.run_with() Gives two paths responsibility for startup Choose one ownership model
uvicorn.run(create_app(), reload=True) Reload subprocesses cannot import the app object Use an import string with factory=True
Calling uvicorn.run() at module import time Importing the module starts a blocking server and breaks subprocess startup Call it from main()
Top-level ui.label(...) with ui.run_with() Script-mode elements are discarded by this integration Register UI in @ui.page functions or a root callable
Multiple workers by default Process-local UI state and WebSockets are not automatically shared Start with one worker and validate a distributed design explicitly
Reconstructing Settings() throughout the app Re-reads sources and obscures the active configuration lifecycle Inject the startup snapshot or use the argument-free provider at framework boundaries
Adding kwargs to cached get_settings() Retains one hidden process-lifetime instance per argument combination Construct explicit Settings(...) overrides and inject them

Verification

Use TestClient as a context manager so the parent ASGI lifespan runs:

from fastapi.testclient import TestClient

from my_app.config import GuiSettings, Settings
from my_app.main import create_app


def test_application_routes() -> None:
    settings = Settings(
        gui=GuiSettings(storage_secret='test-storage-secret'),
    )

    with TestClient(create_app(settings)) as client:
        assert client.get('/healthz').json() == {'status': 'ok'}
        assert client.get('/gui/').status_code == 200

Also verify:

  • startup resources exist while the client context is active and are released afterward
  • the mounted UI returns HTML and parent API failures retain FastAPI's JSON responses
  • uv run my-app starts the server and responds on both the API and UI paths
  • shutdown signals complete without orphaned background tasks

Primary Sources