Skip to content

Class-Based NiceGUI Pages and Integrations: A Practical Guide

Most NiceGUI examples are single-file fever dreams: everything in main.py, global state drifting around like cosmic dust. That's fine for a quick prototype, but the moment you want a real app—auth, dashboards, API integrations—you need structure.

This tutorial shows how to build class-based NiceGUI pages with:

  • A clean project layout
  • Reusable page classes and components
  • Centralized routing
  • Integration points for external services (APIs, DBs, background workers)

Prerequisites

  • Python >= 3.10
  • NiceGUI >= 2.x
  • Basic understanding of Python classes and async/await

1. Project Layout

Let's start with a small but scalable layout:

nicegui-app/
├── app.py                # entrypoint
├── core/
│   ├── __init__.py
│   ├── app_factory.py    # create_app() lives here
│   └── services.py       # "business logic", API clients, db, etc
├── pages/
│   ├── __init__.py
│   ├── base.py           # BasePage class
│   ├── home.py           # HomePage
│   ├── dashboard.py      # DashboardPage
│   └── form_demo.py      # FormPage example
└── requirements.txt

requirements.txt

nicegui>=2.0.0
httpx>=0.27.0

2. The Base Page Pattern

We want each page to be a class that:

  • Knows its own route path
  • Encapsulates UI construction (build(), layout(), etc.)
  • Optionally injects shared services (API clients, repositories)
  • Can be registered in a central router

2.1 pages/base.py

# pages/base.py
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Optional, Any

from nicegui import ui


class BasePage(ABC):
    """Abstract base for all pages.

    Responsibilities:
      - Define `route` (e.g., "/dashboard")
      - Provide `register(app)` to hook into NiceGUI routing
      - Implement `build()` to construct content
    """

    # Route path, to be overridden
    route: str = "/"

    # Optional title; can be used to set browser title, header, etc.
    title: str = "Untitled"

    def __init__(self, services: Optional[dict[str, Any]] = None) -> None:
        self.services = services or {}

    @classmethod
    def register(cls, app, services: Optional[dict[str, Any]] = None) -> None:
        """Attach this page to the NiceGUI app router."""
        page_instance = cls(services=services)

        @ui.page(cls.route)
        def _() -> None:
            page_instance.render()

    def render(self) -> None:
        """Default render flow: layout + build."""
        with ui.column().classes("w-full items-stretch gap-4 p-4"):
            self._render_header()
            self.build()

    def _render_header(self) -> None:
        with ui.row().classes("w-full justify-between items-center"):
            ui.label(self.title).classes("text-2xl font-bold")
            # Hook for page-level actions (buttons, filters, etc.)
            self.render_actions()

    def render_actions(self) -> None:
        """Override in subclasses for header actions."""
        ...

    @abstractmethod
    def build(self) -> None:
        """Subclasses must implement to build the page contents."""
        raise NotImplementedError

This gives us a template:

  • route: where it lives
  • title: display / browser / header
  • render(): calls _render_header() + build()
  • register(app, services): binds it to @ui.page

3. Creating a Simple Home Page

3.1 pages/home.py

# pages/home.py
from __future__ import annotations

from nicegui import ui
from .base import BasePage


class HomePage(BasePage):
    route = "/"
    title = "Welcome to the NiceGUI App"

    def render_actions(self) -> None:
        # Example header actions
        with ui.row().classes("gap-2"):
            ui.button("Dashboard", on_click=lambda: ui.open("/dashboard"))
            ui.button("Form Demo", on_click=lambda: ui.open("/form-demo"))

    def build(self) -> None:
        ui.label("This is the home page, built from a class.").classes("text-lg")
        ui.markdown(
            """
            - Navigate using the header buttons
            - Each page is defined as a Python class
            - Shared services are injected via the BasePage constructor
            """
        )

        with ui.row().classes("gap-4 mt-4"):
            with ui.card():
                ui.label("Quick Card")
                ui.label("You can put any UI components in build().")

            with ui.card():
                ui.label("Another Card")
                ui.button("Click me", on_click=lambda: ui.notify("Clicked on home page!"))

4. Injecting Services: API / DB / Whatever

We don't want each page to instantiate its own HTTP client or database connection. Instead, we define services in one place and pass them down.

4.1 core/services.py

# core/services.py
from __future__ import annotations

from dataclasses import dataclass
from typing import Any
import httpx


@dataclass
class ApiClient:
    base_url: str

    async def get_status(self) -> dict[str, Any]:
        async with httpx.AsyncClient(base_url=self.base_url, timeout=5.0) as client:
            r = await client.get("/status")
            r.raise_for_status()
            return r.json()


def create_services() -> dict[str, Any]:
    """Construct and return the service container."""
    api_client = ApiClient(base_url="https://httpbin.org")
    # Extend here with db clients, redis, etc.
    return {
        "api": api_client,
    }

This gives us a simple "service container" dictionary.

5. A Dashboard Page Using Services

Now let's build a page that calls an API and renders a table / cards.

5.1 pages/dashboard.py

# pages/dashboard.py
from __future__ import annotations

from typing import Any
from nicegui import ui
from .base import BasePage


class DashboardPage(BasePage):
    route = "/dashboard"
    title = "Dashboard"

    async def _refresh_status(self, label: ui.label) -> None:
        api = self.services["api"]
        label.text = "Loading..."
        try:
            data: dict[str, Any] = await api.get_status()
            label.text = f"API status: {data.get('url', 'unknown')}"
            ui.notify("Status refreshed", type="positive")
        except Exception as e:  # noqa: BLE001
            label.text = "Error while fetching status"
            ui.notify(f"Error: {e}", type="negative")

    def build(self) -> None:
        ui.label("Dashboard overview").classes("text-lg mb-2")

        with ui.card().classes("w-full max-w-xl"):
            status_label = ui.label("API status: unknown").classes("mb-2")
            ui.button(
                "Refresh status",
                on_click=lambda: self._refresh_status(status_label),
            )

        with ui.row().classes("gap-4 mt-4"):
            with ui.card():
                ui.label("Metric A")
                ui.label("42")

            with ui.card():
                ui.label("Metric B")
                ui.label("1337")

Note: - We access self.services["api"] - We use an async helper _refresh_status and hook it with on_click - NiceGUI is fine with async callables

6. A Form Page with Validation

Let's add another class-based page that showcases forms, validation, and interactions.

6.1 pages/form_demo.py

# pages/form_demo.py
from __future__ import annotations

from nicegui import ui
from .base import BasePage


class FormDemoPage(BasePage):
    route = "/form-demo"
    title = "Form Demo"

    def build(self) -> None:
        ui.label("User Input Form").classes("text-lg mb-2")

        with ui.card().classes("w-full max-w-lg"):
            name = ui.input("Name").props("outlined").classes("mb-2")
            email = ui.input("Email").props("outlined").classes("mb-2")
            agree = ui.checkbox("I agree to the terms").classes("mb-2")

            result = ui.label("").classes("mt-2 text-sm text-gray-500")

            def submit() -> None:
                errors = []
                if not name.value:
                    errors.append("Name is required.")
                if not email.value or "@" not in email.value:
                    errors.append("Valid email is required.")
                if not agree.value:
                    errors.append("You must agree to the terms.")

                if errors:
                    ui.notify("\n".join(errors), type="negative")
                    result.text = "Errors: " + "; ".join(errors)
                else:
                    ui.notify("Form submitted!", type="positive")
                    result.text = f"Submitted: {name.value} <{email.value}>"

            ui.button("Submit", on_click=submit)

7. Wiring It All Together: App Factory

Now we need a single app factory that:

  • Builds the NiceGUI app
  • Creates the shared services
  • Registers all page classes

7.1 core/app_factory.py

# core/app_factory.py
from __future__ import annotations

from nicegui import ui
from typing import Type

from pages.base import BasePage
from pages.home import HomePage
from pages.dashboard import DashboardPage
from pages.form_demo import FormDemoPage
from core.services import create_services


def create_app():
    """Create and configure the NiceGUI app with class-based pages."""
    services = create_services()

    # List of page classes to register
    page_classes: list[Type[BasePage]] = [
        HomePage,
        DashboardPage,
        FormDemoPage,
    ]

    # Register each page
    for page_cls in page_classes:
        page_cls.register(ui, services=services)

    # Optional: global header/footer, default layouts, etc.
    # For example, a simple top-level menu on every page via NiceGUI's `ui.header`:
    @ui.header()
    def header():
        with ui.row().classes("w-full justify-between items-center px-4 py-2"):
            ui.label("NiceGUI Class-Based App").classes("text-lg font-semibold")
            with ui.row().classes("gap-2"):
                ui.button("Home", on_click=lambda: ui.open("/"))
                ui.button("Dashboard", on_click=lambda: ui.open("/dashboard"))
                ui.button("Form", on_click=lambda: ui.open("/form-demo"))

    return ui

8. Entry Point

8.1 app.py

# app.py
from __future__ import annotations

from core.app_factory import create_app

app = create_app()

if __name__ in {"__main__", "__mp_main__"}:
    # Default NiceGUI run:
    app.run(
        title="NiceGUI Class-Based Pages",
        reload=False,  # set True in dev if desired
        host="0.0.0.0",
        port=8080,
    )

Run it:

# Option 1: Direct Python
python app.py

# Option 2: Uvicorn (if using FastAPI integration)
uvicorn app:app --reload

9. Integration Patterns

Now that the skeleton is alive, here are some patterns for more serious integration.

9.1: FastAPI + NiceGUI in One Process

NiceGUI exposes a FastAPI app via ui.run_with / app.fastapi. A common pattern:

  • Use FastAPI for REST/webhook endpoints
  • Use NiceGUI for UI
  • Share service container between them

Skeleton:

# app.py
from nicegui import ui
from fastapi import FastAPI

from core.app_factory import create_app
from core.services import create_services

services = create_services()
ng_app = create_app()  # registers pages, etc.

fastapi_app = FastAPI()

# Example API endpoint
@fastapi_app.get("/api/ping")
async def ping():
    api = services["api"]
    data = await api.get_status()
    return {"status": "ok", "upstream": data}

ui.run_with(
    fastapi_app,
    title="NiceGUI + FastAPI",
    host="0.0.0.0",
    port=8080,
)

Now:

  • http://localhost:8080/ → NiceGUI UI
  • http://localhost:8080/api/ping → FastAPI JSON

Pages keep using the same service container.

9.2: Page-Level Dependencies

Sometimes you don't want to pass the entire service container; you want typed attributes.

You can wrap the base to provide typed accessors:

# pages/base.py (alt pattern)
from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from core.services import ApiClient

@dataclass
class TypedServices:
    api: "ApiClient"

class BasePage(ABC):
    # ...
    def __init__(self, services: dict[str, Any]) -> None:
        self.services_raw = services
        self.s = TypedServices(api=services["api"])

Then in page:

# pages/dashboard.py
class DashboardPage(BasePage):
    # ...
    async def _refresh_status(self, label: ui.label) -> None:
        data = await self.s.api.get_status()
        # ...

9.3: Background Tasks & Schedulers

For periodic tasks (polling metrics, refreshing dashboards), you can:

  • Use asyncio.create_task from within a page
  • Or integrate external schedulers (APScheduler, Prefect, etc.) and push updates via NiceGUI

Example: simple background update:

# in a Page.build()
import asyncio

counter_label = ui.label("Counter: 0")
self.counter = 0  # attach to instance

async def updater():
    while True:
        await asyncio.sleep(1)
        self.counter += 1
        counter_label.text = f"Counter: {self.counter}"

asyncio.create_task(updater())

Just be aware you're now dancing with concurrency and lifetime—good for small toys, but for production you'll want a more disciplined approach.

10. Integration with Prefect

Let's build a real-world example: a NiceGUI dashboard that displays Prefect flow runs and allows triggering new runs.

10.1 core/services.py (Extended)

# core/services.py (extended)
from __future__ import annotations

from dataclasses import dataclass
from typing import Any
import httpx


@dataclass
class ApiClient:
    base_url: str

    async def get_status(self) -> dict[str, Any]:
        async with httpx.AsyncClient(base_url=self.base_url, timeout=5.0) as client:
            r = await client.get("/status")
            r.raise_for_status()
            return r.json()


@dataclass
class PrefectClient:
    api_url: str
    api_key: str | None = None

    async def get_flow_runs(
        self, limit: int = 10, flow_id: str | None = None
    ) -> list[dict[str, Any]]:
        """Fetch recent flow runs from Prefect API."""
        headers = {}
        if self.api_key:
            headers["Authorization"] = f"Bearer {self.api_key}"

        params = {"limit": limit}
        if flow_id:
            params["flow_id"] = flow_id

        async with httpx.AsyncClient(
            base_url=self.api_url, headers=headers, timeout=10.0
        ) as client:
            r = await client.get("/flow_runs", params=params)
            r.raise_for_status()
            return r.json()

    async def trigger_flow_run(self, deployment_id: str) -> dict[str, Any]:
        """Trigger a new flow run."""
        headers = {}
        if self.api_key:
            headers["Authorization"] = f"Bearer {self.api_key}"

        async with httpx.AsyncClient(
            base_url=self.api_url, headers=headers, timeout=10.0
        ) as client:
            r = await client.post(
                f"/deployments/{deployment_id}/create_flow_run",
                json={},
            )
            r.raise_for_status()
            return r.json()


def create_services() -> dict[str, Any]:
    """Construct and return the service container."""
    import os

    api_client = ApiClient(base_url="https://httpbin.org")
    prefect_client = PrefectClient(
        api_url=os.getenv("PREFECT_API_URL", "http://localhost:4200/api"),
        api_key=os.getenv("PREFECT_API_KEY"),
    )

    return {
        "api": api_client,
        "prefect": prefect_client,
    }

10.2 pages/prefect_dashboard.py

# pages/prefect_dashboard.py
from __future__ import annotations

from typing import Any
from nicegui import ui
from .base import BasePage


class PrefectDashboardPage(BasePage):
    route = "/prefect"
    title = "Prefect Dashboard"

    def __init__(self, services: dict[str, Any] | None = None) -> None:
        super().__init__(services)
        self.flow_runs: list[dict[str, Any]] = []
        self.runs_table: ui.table | None = None

    async def _refresh_runs(self) -> None:
        """Fetch and display Prefect flow runs."""
        prefect = self.services["prefect"]
        try:
            self.flow_runs = await prefect.get_flow_runs(limit=20)
            if self.runs_table:
                self.runs_table.rows = [
                    {
                        "id": run.get("id", "unknown"),
                        "name": run.get("name", "unnamed"),
                        "state": run.get("state", {}).get("type", "unknown"),
                        "start_time": run.get("start_time", "N/A"),
                    }
                    for run in self.flow_runs
                ]
            ui.notify(f"Loaded {len(self.flow_runs)} flow runs", type="positive")
        except Exception as e:  # noqa: BLE001
            ui.notify(f"Error loading runs: {e}", type="negative")

    async def _trigger_run(self, deployment_id: str) -> None:
        """Trigger a new flow run."""
        prefect = self.services["prefect"]
        try:
            result = await prefect.trigger_flow_run(deployment_id)
            ui.notify(f"Flow run created: {result.get('id')}", type="positive")
            await self._refresh_runs()
        except Exception as e:  # noqa: BLE001
            ui.notify(f"Error triggering run: {e}", type="negative")

    def build(self) -> None:
        ui.label("Prefect Flow Runs").classes("text-lg mb-2")

        with ui.row().classes("gap-2 mb-4"):
            ui.button("Refresh", on_click=self._refresh_runs)
            ui.button("Auto-refresh (10s)", on_click=self._start_auto_refresh)

        columns = [
            {"name": "id", "label": "Run ID", "field": "id"},
            {"name": "name", "label": "Name", "field": "name"},
            {"name": "state", "label": "State", "field": "state"},
            {"name": "start_time", "label": "Start Time", "field": "start_time"},
        ]

        self.runs_table = ui.table(
            columns=columns,
            rows=[],
            row_key="id",
        ).classes("w-full")

        # Initial load
        self._refresh_runs()

    def _start_auto_refresh(self) -> None:
        """Start auto-refreshing the table every 10 seconds."""
        import asyncio

        async def refresh_loop():
            while True:
                await asyncio.sleep(10)
                await self._refresh_runs()

        asyncio.create_task(refresh_loop())
        ui.notify("Auto-refresh started", type="info")

10.3 Update app_factory.py

# core/app_factory.py (add PrefectDashboardPage)
from pages.prefect_dashboard import PrefectDashboardPage

# In create_app():
page_classes: list[Type[BasePage]] = [
    HomePage,
    DashboardPage,
    FormDemoPage,
    PrefectDashboardPage,  # Add this
]

11. Integration with PostgreSQL

Let's add a page that queries PostgreSQL and displays data in real-time.

11.1 core/services.py (Add Postgres)

# core/services.py (add Postgres client)
import os
from typing import Any
import asyncpg


@dataclass
class PostgresClient:
    connection_string: str
    pool: asyncpg.Pool | None = None

    async def get_pool(self) -> asyncpg.Pool:
        """Get or create connection pool."""
        if self.pool is None:
            self.pool = await asyncpg.create_pool(self.connection_string)
        return self.pool

    async def query(self, sql: str, *args: Any) -> list[dict[str, Any]]:
        """Execute query and return rows as dicts."""
        pool = await self.get_pool()
        async with pool.acquire() as conn:
            rows = await conn.fetch(sql, *args)
            return [dict(row) for row in rows]

    async def execute(self, sql: str, *args: Any) -> str:
        """Execute statement and return result."""
        pool = await self.get_pool()
        async with pool.acquire() as conn:
            return await conn.execute(sql, *args)


def create_services() -> dict[str, Any]:
    """Construct and return the service container."""
    api_client = ApiClient(base_url="https://httpbin.org")
    prefect_client = PrefectClient(
        api_url=os.getenv("PREFECT_API_URL", "http://localhost:4200/api"),
        api_key=os.getenv("PREFECT_API_KEY"),
    )
    postgres_client = PostgresClient(
        connection_string=os.getenv(
            "DATABASE_URL", "postgresql://user:pass@localhost/dbname"
        )
    )

    return {
        "api": api_client,
        "prefect": prefect_client,
        "postgres": postgres_client,
    }

11.2 pages/postgres_dashboard.py

# pages/postgres_dashboard.py
from __future__ import annotations

from typing import Any
from nicegui import ui
from .base import BasePage


class PostgresDashboardPage(BasePage):
    route = "/postgres"
    title = "PostgreSQL Dashboard"

    def __init__(self, services: dict[str, Any] | None = None) -> None:
        super().__init__(services)
        self.query_result: list[dict[str, Any]] = []
        self.result_table: ui.table | None = None

    async def _execute_query(self, sql_input: ui.textarea) -> None:
        """Execute SQL query and display results."""
        postgres = self.services["postgres"]
        sql = sql_input.value.strip()

        if not sql:
            ui.notify("Please enter a SQL query", type="warning")
            return

        try:
            self.query_result = await postgres.query(sql)
            if self.result_table:
                columns = (
                    [{"name": k, "label": k, "field": k} for k in self.query_result[0].keys()]
                    if self.query_result
                    else []
                )
                self.result_table.columns = columns
                self.result_table.rows = self.query_result
            ui.notify(f"Query returned {len(self.query_result)} rows", type="positive")
        except Exception as e:  # noqa: BLE001
            ui.notify(f"Query error: {e}", type="negative")

    def build(self) -> None:
        ui.label("PostgreSQL Query Interface").classes("text-lg mb-2")

        with ui.card().classes("w-full"):
            sql_input = ui.textarea(
                label="SQL Query",
                placeholder="SELECT * FROM table LIMIT 10;",
            ).classes("w-full mb-2")
            sql_input.value = "SELECT version();"

            ui.button(
                "Execute",
                on_click=lambda: self._execute_query(sql_input),
            ).classes("mb-4")

            columns = [{"name": "result", "label": "Result", "field": "result"}]
            self.result_table = ui.table(
                columns=columns,
                rows=[],
            ).classes("w-full")

11.3 Update requirements.txt

nicegui>=2.0.0
httpx>=0.27.0
asyncpg>=0.29.0

12. Integration with Redis

For caching and real-time updates, let's add Redis support.

12.1 core/services.py (Add Redis)

# core/services.py (add Redis)
import redis.asyncio as redis


@dataclass
class RedisClient:
    url: str
    client: redis.Redis | None = None

    async def get_client(self) -> redis.Redis:
        """Get or create Redis client."""
        if self.client is None:
            self.client = redis.from_url(self.url, decode_responses=True)
        return self.client

    async def get(self, key: str) -> str | None:
        """Get value from Redis."""
        client = await self.get_client()
        return await client.get(key)

    async def set(self, key: str, value: str, ex: int | None = None) -> bool:
        """Set value in Redis."""
        client = await self.get_client()
        return await client.set(key, value, ex=ex)

    async def keys(self, pattern: str = "*") -> list[str]:
        """Get keys matching pattern."""
        client = await self.get_client()
        return await client.keys(pattern)


def create_services() -> dict[str, Any]:
    """Construct and return the service container."""
    import os

    # ... existing services ...
    redis_client = RedisClient(url=os.getenv("REDIS_URL", "redis://localhost:6379"))

    return {
        "api": api_client,
        "prefect": prefect_client,
        "postgres": postgres_client,
        "redis": redis_client,
    }

12.2 pages/redis_dashboard.py

# pages/redis_dashboard.py
from __future__ import annotations

from typing import Any
from nicegui import ui
from .base import BasePage


class RedisDashboardPage(BasePage):
    route = "/redis"
    title = "Redis Dashboard"

    async def _get_value(self, key_input: ui.input, value_label: ui.label) -> None:
        """Get value from Redis."""
        redis_client = self.services["redis"]
        key = key_input.value

        if not key:
            ui.notify("Please enter a key", type="warning")
            return

        try:
            value = await redis_client.get(key)
            value_label.text = f"Value: {value if value else '(nil)'}"
            ui.notify("Value retrieved", type="positive")
        except Exception as e:  # noqa: BLE001
            ui.notify(f"Error: {e}", type="negative")

    async def _set_value(
        self, key_input: ui.input, value_input: ui.input
    ) -> None:
        """Set value in Redis."""
        redis_client = self.services["redis"]
        key = key_input.value
        value = value_input.value

        if not key:
            ui.notify("Please enter a key", type="warning")
            return

        try:
            await redis_client.set(key, value)
            ui.notify(f"Set {key} = {value}", type="positive")
        except Exception as e:  # noqa: BLE001
            ui.notify(f"Error: {e}", type="negative")

    async def _list_keys(self, keys_list: ui.column) -> None:
        """List all keys in Redis."""
        redis_client = self.services["redis"]
        try:
            keys = await redis_client.keys()
            keys_list.clear()
            with keys_list:
                for key in keys[:100]:  # Limit to 100 keys
                    ui.label(key).classes("text-sm")
            ui.notify(f"Found {len(keys)} keys", type="positive")
        except Exception as e:  # noqa: BLE001
            ui.notify(f"Error: {e}", type="negative")

    def build(self) -> None:
        ui.label("Redis Operations").classes("text-lg mb-2")

        with ui.card().classes("w-full max-w-2xl"):
            key_input = ui.input("Key").classes("mb-2")
            value_input = ui.input("Value").classes("mb-2")
            value_label = ui.label("Value: (not loaded)").classes("mb-2")

            with ui.row().classes("gap-2 mb-4"):
                ui.button(
                    "Get",
                    on_click=lambda: self._get_value(key_input, value_label),
                )
                ui.button(
                    "Set",
                    on_click=lambda: self._set_value(key_input, value_input),
                )

            ui.separator()

            ui.label("Keys").classes("text-md font-semibold mb-2")
            keys_list = ui.column().classes("w-full max-h-64 overflow-auto")
            ui.button(
                "List Keys",
                on_click=lambda: self._list_keys(keys_list),
            )

12.3 Update requirements.txt

nicegui>=2.0.0
httpx>=0.27.0
asyncpg>=0.29.0
redis>=5.0.0

13. Data Flow Diagram

flowchart LR
    subgraph Services["Services / Backend"]
        API["ApiClient (httpx)"]
        Prefect["PrefectClient"]
        Postgres["PostgresClient (asyncpg)"]
        Redis["RedisClient"]
    end

    subgraph UI["NiceGUI App"]
        Home["HomePage"]
        Dash["DashboardPage"]
        Form["FormDemoPage"]
        PrefDash["PrefectDashboardPage"]
        PgDash["PostgresDashboardPage"]
        RedisDash["RedisDashboardPage"]
    end

    AppFactory["create_app()"] --> UI
    AppFactory --> Services

    Home --> Services
    Dash --> API
    PrefDash --> Prefect
    PgDash --> Postgres
    RedisDash --> Redis

    Prefect -->|"HTTP / JSON"| PrefAPI["Prefect API"]
    Postgres -->|"SQL"| PgDB["PostgreSQL"]
    Redis -->|"Commands"| RedisServer["Redis Server"]

The core idea:

  • App factory builds services + registers pages
  • Pages take services as dependencies
  • Everything stays in tidy modules instead of a global ball of state

14. Complete Example: All Together

Here's a complete app.py that ties everything together:

# app.py
from __future__ import annotations

import os
from nicegui import ui
from fastapi import FastAPI

from core.app_factory import create_app
from core.services import create_services

# Create services
services = create_services()

# Create NiceGUI app with all pages
ng_app = create_app()

# Create FastAPI app for API endpoints
fastapi_app = FastAPI()


@fastapi_app.get("/api/health")
async def health():
    """Health check endpoint."""
    return {"status": "ok", "services": list(services.keys())}


@fastapi_app.get("/api/services")
async def list_services():
    """List available services."""
    return {"services": list(services.keys())}


# Run NiceGUI with FastAPI
if __name__ in {"__main__", "__mp_main__"}:
    ui.run_with(
        fastapi_app,
        title="NiceGUI Class-Based App",
        reload=os.getenv("NICEGUI_RELOAD", "false").lower() == "true",
        host="0.0.0.0",
        port=int(os.getenv("PORT", "8080")),
    )

15. Checklist for Extending This

If you're going to hand this to an agent and tell it to mutate reality:

  • ✅ Add more page classes under pages/ for specific domains (metrics, admin, logs)
  • ✅ Implement a shared BaseLayout with sidebar navigation and theming
  • ✅ Integrate authentication (e.g., JWT, OIDC) in an AuthPage + guards around routes
  • ✅ Add a SettingsPage that persists config to a DB or file
  • ✅ Add tests for service layer and simple smoke tests for NiceGUI pages (headless)
  • ✅ Add error boundaries and global error handling
  • ✅ Implement real-time updates via WebSockets or Server-Sent Events
  • ✅ Add logging and monitoring integration
  • ✅ Create reusable UI components (cards, tables, forms) in a components/ directory

16. Production Considerations

16.1 Error Handling

Add global error handling:

# core/app_factory.py
@ui.exception_handler
def handle_exception(e: Exception):
    ui.notify(f"Error: {e}", type="negative")
    # Log to your logging service

16.2 Authentication Guards

Add route protection:

# pages/base.py (extended)
class BasePage(ABC):
    # ...
    requires_auth: bool = False

    def render(self) -> None:
        if self.requires_auth and not self._is_authenticated():
            ui.open("/login")
            return
        # ... rest of render

16.3 Environment Configuration

Use environment variables for all configuration:

# core/config.py
import os
from dataclasses import dataclass

@dataclass
class Config:
    prefect_api_url: str = os.getenv("PREFECT_API_URL", "http://localhost:4200/api")
    prefect_api_key: str | None = os.getenv("PREFECT_API_KEY")
    database_url: str = os.getenv("DATABASE_URL", "postgresql://localhost/db")
    redis_url: str = os.getenv("REDIS_URL", "redis://localhost:6379")

See Also