How To Add Multiple Pages to NiceGUI (2026 Guide)
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.

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.
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. 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.
If you’re new to NiceGUI, start with the getting started with NiceGUI guide. If you’re evaluating Python UI frameworks, the Streamlit vs NiceGUI comparison or our roundup of the 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.
Prerequisites
Before you start, make sure you have the following:
- Python 3.10 or newer (
python3 --versionto check) - pip (comes with Python) or setting up a Python project with uv for faster installs
- A text editor or IDE (VS Code with the NiceGUI extension recommended for Tailwind autocomplete)
- ~10 minutes of time
Install NiceGUI with version pinning:
pip install "nicegui>=3.0.0"
Or with uv:
uv pip install "nicegui>=3.0.0"
Verify the install:
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.
Starter repo caveat
The bitdoze/nicegui-starter repo targets NiceGUI v1.x. Use the code from this guide instead for v3.x compatibility.
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
The @contextmanager frame is still the idiomatic NiceGUI pattern for shared layouts. The official modularization example uses the same approach.
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.
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 pageui.header()creates the top bar with a hamburger menu buttonui.left_drawer()creates a slide-out sidebar with navigationui.footer()creates an optional bottom bar (hidden by default, toggled by the FAB button)
Building the navigation menu (menu.py)
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
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
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:
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)
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.
Error: 'ui.page cannot be used in the global scope'
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.
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()
- 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
- 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
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:
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.
Why does sub_pages show a blank page?
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.
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
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
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”).
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.
For another take on multi-page Python apps, see our guide on building a multi-page website with FastHTML.
Which Approach Should You Use?
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.
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.
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.
Verifying and Troubleshooting Your NiceGUI Pages
How to verify all routes are registered
After starting the app:
python main.py
Check these things:
- Open
http://localhost:8080. The home page should load with header and navigation. - Click each menu link. Each page should render its content inside the shared frame.
- Check the terminal for any import errors or warnings
- Confirm the NiceGUI version:
pip show nicegui(should be ≥ 3.0.0)
Common errors and fixes
RuntimeError: 'ui.page cannot be used in the global scope'
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.
ImportError for theme or menu module
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.
Pages show a 'sad face' error page
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.
Navigation links don't work / 404
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().
response_timeout exceeded
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.
Layout looks wrong after upgrading from v1/v2
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.
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.
Quick Docker Compose example:
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.storagefor per-user state or theEventclass (v3.0+) for cross-client communication. - Self-hosting: For affordable VPS hosting, Hetzner Cloud offers plans starting at ~€4/month. Hostinger VPS is another budget option with KVM virtualization and NVMe storage.
- Deployment platforms: You can self-host with Dokploy or check the best self-hosted server 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 oldui.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
.tailwindPython API. ui.keep_alive(): Prevents idle client disconnections (added in v3.11). Useful for long-lived dashboard sessions.
Quick v3 migration checklist
- Replace
ui.open()withui.navigate.to() - Remove
.tailwindAPI usage — use.classes()instead - Remove
.update()calls after.props()/.classes()/.style() - Ensure Python 3.10 or newer
- Pin
nicegui>=3.0.0in requirements - Move all UI inside
@ui.page()functions (no global-scope elements)
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 layoutsui.sub_pagesfor dashboards and internal tools with consistent navigation — this is the recommended default for most new projectsAPIRouterfor 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. For the broader ecosystem context, see the best Python web frameworks roundup.
NiceGUI Official DocumentationFrequently Asked Questions
Can I use NiceGUI with Tailwind CSS?
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.
How do I share state between pages?
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.
Is NiceGUI suitable for production?
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.
What's the difference between sub_pages and @ui.page()?
@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.


