DEV Community

India Owens
India Owens

Posted on

Multi-Page Routing in Shiny for Python with Starlette

The problem with existing routing examples

This is Part 2 of the Shiny for Python: The Missing Manual series. Part 1 covered extending Shiny with Quill.js for rich text input. This article stands alone, but if you're building a full internal tool, reading them in order will give you the complete picture.

Search for Shiny for Python routing and you'll find the same pattern repeated: one Shiny app mounted at one route, one static HTML page at another. It demonstrates the concept, but it doesn't solve the real problem.

In a production internal tool, you typically want something that behaves like a normal website — multiple pages, clean URLs, consistent navigation, and critically, shared server state across all of them. Maybe page one is a data entry form and page two is a live view of the database. The navigation bar links them, but they're running the same reactive logic underneath.

That's what this article covers. We'll build a two-page application: a form that writes names to a SQLite database, and a table view that reads from it. Both pages share a single server function, and Starlette handles the routing that ties them together.

Architecture overview

Before the code, here's how the pieces fit together:

Request from browser

Starlette (main.py)

─ /submit ──▶

submit.py (form UI)

Starlette (main.py)

─ /view ────▶

view.py (table UI)

Both import ▼

server.py (shared reactive logic)

Reads/writes ▼

myshinyapp.db (SQLite)
Enter fullscreen mode Exit fullscreen mode

The key architectural decision is the shared server. Both Shiny apps import the same server_function from server.py. In a simple demo this doesn't matter much, but in a real application where pages share global state, utility functions, database connections, or config — this structure pays off immediately. It also keeps the codebase clean as the app grows.

💡 Two patterns, pick one You can also use Starlette to mount completely separate Shiny apps under different routes without sharing a server — useful if the pages are truly independent. This article shows the shared-server pattern because it's the more production-relevant one and the less documented one.

File structure

project layout

your_app/
├── main.py        # Starlette app — the entry point for uvicorn
├── server.py      # Shared Shiny server function
├── submit.py      # Form page UI + App object
├── view.py        # Table view page UI + App object
└── myshinyapp.db  # SQLite database (created on first run)
Enter fullscreen mode Exit fullscreen mode

Step 1: The shared server

Start here because everything else depends on it. The server function handles all reactivity for both pages — form submission, database writes, and table rendering. Separating it into its own file isn't just organizational preference: it's what makes sharing it between two App objects possible.

server.py

server.py

import sqlite3
import pandas as pd
from shiny import render, reactive

DB_NAME = "myshinyapp.db"

# Initialize the database table if it doesn't exist yet
def init_db():
    with sqlite3.connect(DB_NAME) as conn:
        conn.execute(
            """CREATE TABLE IF NOT EXISTS name (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                first_name TEXT NOT NULL,
                last_name  TEXT NOT NULL
            )"""
        )

init_db()

def server_function(input, output, session):

    @render.table
    @reactive.event(input.refresh, ignore_none=False)
    def data_table():
        # ignore_none=False means the table loads on page open
        # without the user having to click Refresh first
        with sqlite3.connect(DB_NAME) as conn:
            return pd.read_sql_query(
                "SELECT * FROM name ORDER BY id DESC", conn
            )

    @render.text
    @reactive.event(input.submit)
    def status():
        first_name = input.first_name()
        last_name  = input.last_name()
        if first_name and last_name:
            with sqlite3.connect(DB_NAME) as conn:
                conn.execute(
                    "INSERT INTO name (first_name, last_name) VALUES (?, ?)",
                    (first_name, last_name)
                )
            return f"Saved: {first_name} {last_name}"
        return "Please enter both first and last name."
Enter fullscreen mode Exit fullscreen mode

✅ Note on ignore_none=False Setting ignore_none=False on the refresh handler means the table renders immediately when the page loads — the user doesn't have to click Refresh to see data. Remove it if you want explicit user-triggered loads only.

Step 2: The page UIs

Each page is its own Python file with its own UI definition and its own App object — but both import the same server function. The navigation links use relative paths (../submit, ../view) which Starlette resolves correctly once the routes are mounted.

submit.py — the data entry form

submit.py

from shiny import App, ui
from server import server_function

form_ui = ui.page_navbar(
    ui.nav_panel(
        "Data Entry Form",
        ui.card(
            ui.input_text("first_name", "First Name"),
            ui.input_text("last_name",  "Last Name"),
            ui.input_action_button("submit", "Submit", class_="btn-primary"),
            ui.output_text("status"),
        )
    ),
    # Nav link to the other page — relative path resolved by Starlette
    ui.nav_control(ui.a("Database Viewer", href="../view")),
)

app = App(form_ui, server_function)
Enter fullscreen mode Exit fullscreen mode

view.py — the database table

view.py

from shiny import App, ui
from server import server_function

display_ui = ui.page_navbar(
    ui.nav_control(ui.a("Data Entry Form", href="../submit")),
    ui.nav_panel(
        "Database Viewer",
        ui.input_action_button("refresh", "Refresh Table"),
        ui.hr(),
        ui.output_table("data_table"),
    )
)

app = App(display_ui, server_function)
Enter fullscreen mode Exit fullscreen mode

Step 3: Starlette ties it all together

This is the file Uvicorn runs. Starlette mounts each Shiny App object at its respective route, and a root redirect handler means users who navigate to / land on the form automatically rather than hitting a 404.

⚠️ Don't skip the root redirect Without it, users have to manually navigate to yourapp.com/submit. That's fine in development but breaks the experience in production. The redirect adds three lines and costs nothing.

main.py — the Starlette entry point

main.py

from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.responses import RedirectResponse
from submit import app as form_app
from view import app as display_app

async def homepage_redirect(request):
    # Preserve the root_path so this works behind a reverse proxy
    root_path = request.scope.get("root_path", "")
    return RedirectResponse(url=f"{root_path}/submit")

routes = [
    Mount("/submit", app=form_app),
    Mount("/view",   app=display_app),
    Route("/",       endpoint=homepage_redirect),
]

# This is what uvicorn targets — not the individual Shiny apps
app = Starlette(routes=routes)
Enter fullscreen mode Exit fullscreen mode

Running the app

Because Starlette is the outer application, you run main.py via Uvicorn — not the individual Shiny files directly:

terminal

# Install dependencies if you haven't already
pip install shiny starlette uvicorn pandas

# Run the app
uvicorn main:app --reload

# App is now available at:
#   http://localhost:8000/         → redirects to /submit
#   http://localhost:8000/submit   → data entry form
#   http://localhost:8000/view     → database table
Enter fullscreen mode Exit fullscreen mode

Gotchas worth knowing before you hit them

  • The shared server doesn't mean shared reactive state between pages. Each page gets its own Shiny session when a user visits it. Shared server means shared code — database connections, utility functions, constants — not a live reactive context that persists across page navigations.
  • Navigation links use relative paths. ../submit and ../view are resolved by Starlette relative to the mounted route. If you change your mount paths in main.py, update the href values in your UI files to match.
  • Run via main.py, not the individual Shiny files. Running shiny run submit.py directly will work in isolation but won't give you routing. Uvicorn on main.py is the correct entry point for the full application.
  • The root_path in the redirect matters behind a proxy. If you're deploying behind Nginx or a similar reverse proxy that strips a path prefix, request.scope.get('root_path', '') ensures the redirect still lands correctly. Don't hardcode the base URL.

💡 When to use this pattern vs. separate apps Use the shared server pattern when your pages need common utility functions, share global config, or access the same database connection pool. Use fully separate Shiny apps mounted under different routes when the pages are genuinely independent and you want clean isolation — for example, separate tools for separate teams under one domain.


What this unlocks

With Starlette routing in place, Shiny for Python can behave like a real multi-page web application — clean URLs, shared logic, and a consistent navigation experience. The pattern scales cleanly: adding a third page means creating a new UI file, importing the server, and adding one more Mount in main.py.

The full working code for both versions is available on GitHub. The next part of this series covers adding interactive buttons directly inside a dataframe — and using routing and URL parameters to navigate to an individual record's detail page, building on the multi-page pattern we set up here.


References

  1. Shiny for Python — Official Routing Documentation — Posit
  2. Shiny for Python Routing — Appsilon
  3. Shiny Router: A Simple Routing Package for Shiny — Appsilon (R only — no Python package yet, but useful for understanding the pattern)

Top comments (0)