---
title: "How To Add Multiple Pages to NiceGUI (2026 Guide)"
description: "Learn to add multiple pages to your NiceGUI app with @ui.page() routing, shared layouts, and SPA navigation. Updated for NiceGUI v3: sub_pages, APIRouter, and best practices."
date: 2026-07-27
categories: ["web-development"]
tags: ["nicegui","python","ui-framework"]
---

import YouTubeEmbed from "../../components/widgets/YouTubeEmbed.astro";
import Notice from "../../components/widgets/Notice.astro";
import ListCheck from "../../components/widgets/ListCheck.astro";
import Tabs from "../../components/widgets/Tabs.astro";
import Tab from "../../components/widgets/Tab.astro";
import Accordion from "../../components/widgets/Accordion.astro";
import Button from "../../components/widgets/Button.astro";

# How To Add Multiple Pages to NiceGUI (2026 Guide)

NiceGUI (16k+ GitHub stars) is a Python UI framework for building web interfaces in Python, no JavaScript required. Most apps beyond a quick prototype need multiple pages with shared navigation: a header, a sidebar, and content that changes based on the URL.

This guide covers three approaches to NiceGUI page routing: the classic `@ui.page()` with a shared frame, SPA-style navigation with `ui.sub_pages`, and modular routing with `APIRouter`. Pick the one that matches your app's complexity.

<Notice type="info" title="Updated for NiceGUI v3.15">
This guide was updated for NiceGUI v3.x (tested with v3.15.0). If you're migrating from v1.x or v2.x, check the official [v3 migration guide](https://zenodo.org/records/17259155). Key changes: Python 3.10+ required, `.tailwind` API removed, `ui.open()` replaced by `ui.navigate.to()`, and the auto-index shared client no longer exists.
</Notice>

If you're new to NiceGUI, start with the [getting started with NiceGUI](/nicegui-get-started/) guide. If you're evaluating Python UI frameworks, the [Streamlit vs NiceGUI comparison](/streamlit-vs-nicegui/) or our roundup of the [best Python web frameworks](/best-python-web-frameworks/) can help you decide.

The video below shows the original approach using NiceGUI v1.x. The code patterns still apply. The `@ui.page()` + `@contextmanager` frame approach is still the idiomatic NiceGUI pattern. See the v3-specific notes throughout this guide.

<YouTubeEmbed
  url="https://www.youtube.com/embed/bW3ifL2hdfc"
  label="How To Add Multiple Pages to NiceGUI"
/>

## Prerequisites

Before you start, make sure you have the following:

<ListCheck>
<ul>
  <li>Python 3.10 or newer (`python3 --version` to check)</li>
  <li>pip (comes with Python) or [setting up a Python project with uv](/uv-get-start/) for faster installs</li>
  <li>A text editor or IDE (VS Code with the NiceGUI extension recommended for Tailwind autocomplete)</li>
  <li>~10 minutes of time</li>
</ul>
</ListCheck>

Install NiceGUI with version pinning:

```bash
pip install "nicegui>=3.0.0"
```

Or with uv:

```bash
uv pip install "nicegui>=3.0.0"
```

Verify the install:

```bash
pip show nicegui
```

You should see `Version: 3.x.x` in the output. If you see a version below 3.0, upgrade with `pip install --upgrade nicegui`.

## Project Structure for a NiceGUI Multi-Page App

Keeping your files organized from the start saves headaches when you add more pages later. Here's the folder layout we'll build:

```
my_nicegui_app/
├── main.py              # Entry point — starts the server
├── theme.py             # Shared layout frame (header, drawer, footer)
├── menu.py              # Navigation menu links
├── home_page.py         # Home page content
├── all_pages.py         # Route registration
├── pages/
│   ├── __init__.py      # Empty — makes pages a proper Python package
│   ├── title_generator.py
│   └── script_generator.py
├── requirements.txt
└── .gitignore
```

Each file has a single job. `theme.py` defines the shared layout. `menu.py` defines the navigation links. The `pages/` folder holds individual page content. `all_pages.py` wires up the routes. `main.py` starts it all.

<Notice type="warning" title="Starter repo caveat">
The <a href="https://github.com/bitdoze/nicegui-starter">bitdoze/nicegui-starter</a> repo targets NiceGUI v1.x. Use the code from this guide instead for v3.x compatibility.
</Notice>

### Requirements and version pinning

Pin the major version in `requirements.txt`. Drop `numpy` unless you use it (the example pages here don't):

```
nicegui>=3.0.0
```

Don't skip `pages/__init__.py`. It can be an empty file, but it's needed for proper Python packaging. Tools like PyInstaller and Nuitka require it.

## Approach 1: Page Routing with @ui.page() and a Shared Frame

This is the "traditional" NiceGUI page routing pattern. Each URL gets its own `@ui.page()` decorator, and they all share a layout via a `@contextmanager` frame. Use this when:

- You want independent page instances (each client gets a fresh UI tree)
- Pages have very different layouts
- You're building a simple multi-page site with a few routes

<Notice type="info">
The `@contextmanager` frame is still the idiomatic NiceGUI pattern for shared layouts. The official <a href="https://github.com/zauberzeug/nicegui/blob/main/examples/modularization/theme.py">modularization example</a> uses the same approach.
</Notice>

### Creating the shared layout (theme.py)

This file defines the page frame: header, sidebar drawer, footer, and the content area. Every page calls `theme.frame()` and puts its content inside.

```python
from contextlib import contextmanager

from menu import menu

from nicegui import ui


@contextmanager
def frame(navtitle: str):
    """Custom page frame to share the same styling and behavior across all pages"""
    ui.colors(primary='#6E93D6', secondary='#53B689', accent='#111B1E', positive='#53B689')
    with ui.column().classes('absolute-center items-center h-screen no-wrap p-9 w-full'):
        yield
    with ui.header() as header:
        ui.button(on_click=lambda: left_drawer.toggle(), icon='menu').props('flat color=white')
        ui.label('Getting Started').classes('font-bold')

    with ui.footer(value=False) as footer:
        ui.label('Footer')
    with ui.left_drawer().classes('bg-blue-100') as left_drawer:
        ui.label('Menu')
        with ui.column():
            menu()
    with ui.page_sticky(position='bottom-right', x_offset=20, y_offset=20):
        ui.button(on_click=footer.toggle, icon='contact_support').props('fab')
```

The `yield` statement is where page content gets inserted. The `@contextmanager` decorator makes this reusable. Every page calls `with theme.frame('Page Title'):` and puts its UI elements inside the block.

Key points:
- `ui.colors()` sets the color scheme for the entire page
- `ui.header()` creates the top bar with a hamburger menu button
- `ui.left_drawer()` creates a slide-out sidebar with navigation
- `ui.footer()` creates an optional bottom bar (hidden by default, toggled by the FAB button)

### Building the navigation menu (menu.py)

```python
from nicegui import ui


def menu() -> None:
    ui.link('Home', '/').classes('text-black')
    ui.link('YouTube Titles', '/youtube-title-generator/').classes('text-black')
    ui.link('YouTube Script Generator', '/youtube-script/').classes('text-black')
```

Each `ui.link()` creates a clickable navigation link. The first argument is the display text, the second is the URL path. For programmatic navigation (e.g., after a form submit), use `ui.navigate.to('/new-page')` instead of links.

### Defining page content (pages/)

Each page file defines a function that uses the `theme.frame()` context manager. All UI creation happens inside the function, never at module level (this is a hard requirement in NiceGUI v3).

**pages/title_generator.py**

```python
import theme
from nicegui import ui


def title_generator():
    with theme.frame('YouTube Title Generator'):
        ui.page_title('YouTube Title Generator')
        ui.markdown('# Title Generator')
        ui.markdown('Generate catchy titles for your YouTube videos.')
```

**pages/script_generator.py**

```python
import theme
from nicegui import ui


def script_generator():
    with theme.frame('YouTube Script Generator'):
        ui.page_title('YouTube Script Generator')
        ui.markdown('# Script Generator')
        ui.markdown('Create video scripts with AI assistance.')
```

Don't forget `pages/__init__.py`. Just create an empty file. Without it, Python won't treat `pages` as a package.

### Registering routes (all_pages.py)

This file maps URL paths to page functions using `ui.page()` decorators:

```python
from nicegui import ui
from pages.title_generator import title_generator
from pages.script_generator import script_generator


def create() -> None:
    @ui.page('/youtube-title-generator/')
    def title_page():
        title_generator()

    @ui.page('/youtube-script/')
    def script_page():
        script_generator()
```

The `create()` function is called once from `main.py` to register all routes before the server starts.

### Putting it together (main.py)

```python
import all_pages
import home_page
import theme

from nicegui import ui


@ui.page('/')
def index_page() -> None:
    with theme.frame('Homepage'):
        home_page.content()


all_pages.create()

ui.run(title='Getting Started With NiceGUI')
```

**Verify**: Run `python main.py`, then open `http://localhost:8080`. You should see the header with a hamburger menu, the sidebar with navigation links, and the home page content. Click each menu link to confirm navigation works.

<Accordion label="Error: 'ui.page cannot be used in the global scope'" group="errors">
This happens when you have UI creation at the module level alongside `@ui.page()` decorators. NiceGUI v3 removed the auto-index shared client. ALL UI must be inside `@ui.page()` functions. Move any top-level `ui.element()`, `ui.label()`, etc. calls inside a page function.
</Accordion>

## Approach 2: SPA Navigation with ui.sub_pages

`ui.sub_pages` is the modern NiceGUI approach for apps where you want client-side navigation. The header and sidebar stay fixed, and only the content area swaps. No full page reload, no flash of unstyled content.

This is what most readers building dashboards or internal tools actually want.

### When to use sub_pages vs @ui.page()

<Tabs>
<Tab name="sub_pages (SPA)">
- Client-side content swap, no page reload
- Header and sidebar persist across navigation
- URL updates in the browser bar
- Best for dashboards and internal tools with consistent navigation
- Added in NiceGUI v2.22.0, stable in v3
</Tab>
<Tab name="@ui.page() (traditional)">
- Full page reload per route
- Each page gets an independent client instance
- Pages can have completely different layouts
- Better for public-facing sites with distinct page designs
- Has been the standard approach since NiceGUI 1.x
</Tab>
</Tabs>

### Complete sub_pages example

Here's a self-contained SPA app. Everything goes in `main.py` for clarity. In a real project, you'd split the page functions into separate files:

```python
from nicegui import ui


def menu():
    ui.link('Home', '/').classes('text-black')
    ui.link('YouTube Titles', '/youtube-title-generator/').classes('text-black')
    ui.link('YouTube Script Generator', '/youtube-script/').classes('text-black')


def home():
    ui.markdown('# Welcome')
    ui.markdown('Select a tool from the sidebar.')


def title_generator():
    ui.markdown('# Title Generator')
    ui.markdown('Generate catchy titles for your YouTube videos.')


def script_generator():
    ui.markdown('# Script Generator')
    ui.markdown('Create video scripts with AI assistance.')


@ui.page('/')
def root():
    ui.colors(primary='#6E93D6', secondary='#53B689', accent='#111B1E', positive='#53B689')
    with ui.header():
        ui.button(on_click=lambda: left_drawer.toggle(), icon='menu').props('flat color=white')
        ui.label('My App').classes('font-bold')
    with ui.left_drawer().classes('bg-blue-100') as left_drawer:
        with ui.column():
            menu()
    with ui.column().classes('p-4 w-full'):
        ui.sub_pages({
            '/': home,
            '/youtube-title-generator/': title_generator,
            '/youtube-script/': script_generator,
        })


ui.run(title='My App')
```

**Verify**: Run the app. Click menu links. The URL should change and the content area should swap without a full page reload. The header and sidebar remain fixed. Use your browser's back button to confirm history navigation works.

<Accordion label="Why does sub_pages show a blank page?" group="errors">
Ensure the path dict keys in `ui.sub_pages()` match your menu link paths exactly, including trailing slashes. The root path must be `'/'`. Also make sure the menu `ui.link()` paths match the dictionary keys character for character.
</Accordion>

## Approach 3: Modular Routing with APIRouter

For larger apps where you want to group related pages under a URL prefix and keep them in separate modules, `nicegui.APIRouter` works like FastAPI's `APIRouter`. This scales well when you have many feature groups and multiple developers.

### Grouping pages with shared URL prefixes

**api_pages.py**

```python
from nicegui import APIRouter, ui

router = APIRouter(prefix='/tools')


@router.page('/')
def tools_index():
    ui.markdown('# Tools')
    ui.markdown('Select a tool from the list.')


@router.page('/generator/{name}')
def generator(name: str):
    ui.markdown(f'# Generator: {name}')
    ui.markdown(f'This is the {name} generator page.')
```

**main.py**

```python
import api_pages
from nicegui import app, ui


@ui.page('/')
def index():
    ui.markdown('# Home')
    ui.link('Go to Tools', '/tools/')


app.include_router(api_pages.router)

ui.run(title='APIRouter Example')
```

**Verify**: Run the app. Visit `/tools/` to confirm the tools index loads. Visit `/tools/generator/title` to confirm the dynamic path parameter works (you should see "Generator: title").

<Notice type="warning" title="NiceGUI On Air limitation">
APIRouter does not currently work with NiceGUI On Air (the hosted deployment service). Use Approach 1 or 2 if you plan to deploy via On Air.
</Notice>

For another take on multi-page Python apps, see our guide on building a [multi-page website with FastHTML](/fasthtml-multiple-pages/).

## Which Approach Should You Use?

<Tabs>
<Tab name="Simple app">
**Use Approach 1** (`@ui.page()` + frame) when you have a few pages with different layouts, or you want each page to be an independent instance. Good for public-facing sites.
</Tab>
<Tab name="Dashboard / internal tool">
**Use Approach 2** (`ui.sub_pages`), the recommended default for most readers. SPA-style navigation, persistent shell, fast client-side transitions. Use this for dashboards, admin panels, and internal tools.
</Tab>
<Tab name="Large app, many features">
**Use Approach 3** (`APIRouter`) when you have many feature groups, need URL prefixes, and want to keep modules cleanly separated. Scales well for team development.
</Tab>
</Tabs>

## Verifying and Troubleshooting Your NiceGUI Pages

### How to verify all routes are registered

After starting the app:

```bash
python main.py
```

Check these things:

1. Open `http://localhost:8080`. The home page should load with header and navigation.
2. Click each menu link. Each page should render its content inside the shared frame.
3. Check the terminal for any import errors or warnings
4. Confirm the NiceGUI version: `pip show nicegui` (should be ≥ 3.0.0)

### Common errors and fixes

<Accordion label="RuntimeError: 'ui.page cannot be used in the global scope'" group="errors">
Move all UI creation inside `@ui.page()` functions. NiceGUI v3 removed the auto-index shared client. Global-scope UI elements are no longer allowed. If you have `ui.label('something')` at the top level of a file, wrap it in a page function.
</Accordion>

<Accordion label="ImportError for theme or menu module" group="errors">
Run `python main.py` from the project root directory (where `theme.py` and `menu.py` live). Make sure `pages/__init__.py` exists (empty file is fine). If using a nested structure, check your `PYTHONPATH`.
</Accordion>

<Accordion label="Pages show a 'sad face' error page" group="errors">
Check the terminal for the Python traceback. The page function raised an exception. Common causes: missing import, typo in an element name, or referencing a variable that doesn't exist in the page scope.
</Accordion>

<Accordion label="Navigation links don't work / 404" group="errors">
Verify route paths match exactly, including trailing slashes. `/youtube-title-generator/` is not the same as `/youtube-title-generator`. The paths in `ui.link()` must match the paths in `@ui.page()` or `ui.sub_pages()`.
</Accordion>

<Accordion label="response_timeout exceeded" group="errors">
Async page builders that take more than 3 seconds need a higher timeout: `@ui.page('/', response_timeout=10)`. This happens when pages do heavy computation or network calls during initialization.
</Accordion>

<Accordion label="Layout looks wrong after upgrading from v1/v2" group="errors">
NiceGUI v3 ships with Tailwind 4 (upgraded from Tailwind 3). Some utility classes behave differently, particularly `line-height`, `border` defaults, and spacing. Check the Tailwind 4 changelog if your layout shifted after upgrading.
</Accordion>

## Deploying Your Multi-Page NiceGUI App

### Docker and reverse proxy

NiceGUI runs as a single uvicorn worker: one process handles all client connections. For production, put it behind a reverse proxy (Nginx, Caddy, or Traefik) and use Docker for reproducible deploys.

The official NiceGUI Docker image is `zauberzeug/nicegui`. For a complete guide on containerizing Python apps, see [deploy Python apps with Docker](/docker-run-python/).

Quick Docker Compose example:

```yaml
services:
  nicegui:
    image: zauberzeug/nicegui:latest
    ports:
      - "8080:8080"
    volumes:
      - .:/app
    environment:
      - PORT=8080
      - HOST=0.0.0.0
```

NiceGUI v3.10+ correctly reads `client.ip` behind reverse proxies. Make sure your proxy sets `X-Forwarded-For` and `X-Forwarded-Proto` headers.

### Production notes

Key things to know before running this in production:

- **Single worker process**: All clients share one process. Each `@ui.page()` connection holds its own UI tree in memory. For high-traffic apps, scale horizontally with Docker replicas behind a load balancer.
- **Shared state**: Each client gets its own UI instance. Use `app.storage` for per-user state or the `Event` class (v3.0+) for cross-client communication.
- **Self-hosting**: For affordable VPS hosting, [Hetzner Cloud](https://go.bitdoze.com/hetzner) offers plans starting at ~€4/month. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) is another budget option with KVM virtualization and NVMe storage.
- **Deployment platforms**: You can self-host with [Dokploy](/dokploy-install/) or check the [best self-hosted server panels](/best-self-hosted-panels/) for more options.

## Bonus Tips for NiceGUI v3

A few things worth knowing that don't fit neatly into the sections above:

- **Programmatic navigation**: Use `ui.navigate.to('/new-page')` — the old `ui.open()` was removed in v3.
- **Observable props**: `.props()`, `.classes()`, and `.style()` no longer need `.update()` calls. They're automatically reactive in v3.
- **Tailwind 4**: NiceGUI v3 ships Tailwind 4. If your classes look off after upgrading, check the Tailwind 4 changelog for breaking changes around line-height and border defaults.
- **VSCode NiceGUI extension**: Recommended for Tailwind class autocomplete. This replaces the removed `.tailwind` Python API.
- **`ui.keep_alive()`**: Prevents idle client disconnections (added in v3.11). Useful for long-lived dashboard sessions.

<Accordion label="Quick v3 migration checklist" group="faq">
<ListCheck>
<ul>
  <li>Replace `ui.open()` with `ui.navigate.to()`</li>
  <li>Remove `.tailwind` API usage — use `.classes()` instead</li>
  <li>Remove `.update()` calls after `.props()` / `.classes()` / `.style()`</li>
  <li>Ensure Python 3.10 or newer</li>
  <li>Pin `nicegui>=3.0.0` in requirements</li>
  <li>Move all UI inside `@ui.page()` functions (no global-scope elements)</li>
</ul>
</ListCheck>
</Accordion>

## Conclusion

Adding multiple pages to NiceGUI comes down to choosing the right pattern for your app:

- **`@ui.page()` + shared frame** for simple multi-page sites with distinct layouts
- **`ui.sub_pages`** for dashboards and internal tools with consistent navigation — this is the recommended default for most new projects
- **`APIRouter`** for large apps with many feature groups and team development

The `ui.sub_pages` approach is what I'd reach for first on any new NiceGUI project. The client-side navigation feels faster, the persistent shell avoids layout flashing, and the code is simpler — everything in one `@ui.page('/')` handler with a path-to-function dictionary.

If you're building AI-powered tools on top of NiceGUI, check out our guide on [building AI tools with Python](/build-ai-agent-mastra/). For the broader ecosystem context, see the [best Python web frameworks](/best-python-web-frameworks/) roundup.

<Button text="NiceGUI Official Documentation" link="https://nicegui.io/documentation" variant="solid" color="blue" size="md" icon="arrow-right" />

## Frequently Asked Questions

<Accordion label="Can I use NiceGUI with Tailwind CSS?" group="faq">
Yes. NiceGUI v3 ships with Tailwind 4. Use `.classes('your-tailwind-classes')` on any element. The old `.tailwind` Python API (e.g., `ui.element.tailwind.font_bold()`) was removed in v3 — use the string-based `.classes()` approach instead.
</Accordion>

<Accordion label="How do I share state between pages?" group="faq">
In v3, each `@ui.page()` creates a new client instance — state is not shared between users by default. Use `app.storage` for per-user persistent state. For cross-client communication (e.g., a dashboard showing live sensor data to all users), use the `Event` class introduced in v3.0.
</Accordion>

<Accordion label="Is NiceGUI suitable for production?" group="faq">
Yes, but run it behind a reverse proxy (Nginx, Caddy, or Traefik). NiceGUI is a single-worker process — one uvicorn process handles all client connections. For high-traffic apps, scale horizontally with multiple Docker replicas behind a load balancer. Each client connection holds its own UI tree, so watch memory usage.
</Accordion>

<Accordion label="What's the difference between sub_pages and @ui.page()?" group="faq">
`@ui.page()` does a full page reload per route — the browser requests a new page from the server. `ui.sub_pages` swaps content client-side within a shared shell (header, sidebar stay fixed). Use `sub_pages` for dashboards and apps with consistent navigation. Use `@ui.page()` when pages need completely different layouts or independent client instances.
</Accordion>