A spatial dashboard almost never stays one page. The first version is a map with filters; the second adds a table view; by the third there is a comparison page, an export page and an admin page, and something that used to be obvious has quietly become hard — an analyst pans to a region on the map page, switches to the table, comes back, and the map is at its default viewport again. The filters have reset. The layer they loaded is being loaded a second time.

Multi-page navigation is where the state model of a dashboard framework stops being an implementation detail. Streamlit reruns a different script per page, and Panel serves a different template; in both cases the objects your page built are gone and only whatever you deliberately put somewhere durable survives. This page is about deciding what that somewhere is, what belongs in it, and how to keep a heavy spatial layer from being reloaded every time somebody clicks a link.

Jump to heading The problem statement

Three distinct kinds of state cross a page boundary, and conflating them is the source of most multi-page bugs.

Navigation state is where the analyst is and what they were looking at: the viewport, the selected region, the active filters, the row they had highlighted. It is small, it is per-session, and losing it is the failure users actually notice.

Data state is the layer itself — a GeoDataFrame that took two seconds and four hundred megabytes to produce. It is large, it is identical for every session looking at the same region, and putting it in per-session storage is how a dashboard runs out of memory with six users connected.

Derived state is everything computed from the other two: a filtered subset, a rendered figure, an aggregate. It is cheap to recompute from the first two and expensive to store, and the correct default is to recompute it.

The rule that follows is short: navigation state goes in session storage, data state goes in a cache keyed by its inputs, and derived state goes nowhere. Almost every “why is this page slow” and “why did my selection disappear” question resolves to one of those three being in the wrong place.

Three kinds of state, three destinationsNavigation state — the viewport, the selected region, the active filters — is small and per-session, so it belongs in session storage; put it in a cache instead and every analyst shares one person's viewport. Data state is the loaded layer: large, identical across sessions viewing the same region, and therefore belonging in a cache keyed by its inputs; put it in session storage instead and the same four hundred megabytes is held once per connected user. Derived state is anything computed from the first two — a filtered subset, a rendered figure, an aggregate — and belongs nowhere, because recomputing it is cheaper than storing it and far cheaper than keeping it consistent; store it anyway and it becomes stale relative to the inputs it was derived from, producing a page that shows a filter and a result that disagree.WHAT CROSSES A PAGE BOUNDARY, AND WHERE IT LIVESnavigation stateviewport · selection · filterssmall, and per person→ session storagest.session_state · pn.state.cachekeyed by session, not by contentmisplaced into a cache instead:every analyst shares one person's viewport, and themap jumps whenever somebody else pansdata statethe loaded layer itselflarge, and identical for all→ a cache, keyed by inputsshared across sessions and pagesbounded by ttl and max_entriesmisplaced into session storage instead:400 MB held once per connected user, so six analystsexhaust a container sized for onederived statesubsets · figures · totalscheap from the other two→ nowhere; recomputeit has no independent identityand nothing to invalidate againststored anyway:it goes stale against the inputs it came from, and thepage shows a filter and a result that disagreeNearly every "why did my selection disappear" and "why is this page slow" question is one of these three in the wrong column.

Jump to heading Prerequisites

  • streamlit>=1.36 for the st.Page and st.navigation API, or panel>=1.4 if you are building a multi-template Panel application.
  • A working understanding of session state patterns — this page assumes you already know why a viewport belongs in session state and takes the next step of getting it across a navigation event.
  • A cached loader for your layers, as covered in @st.cache_data implementation. Multi-page navigation makes an uncached loader painful in a way a single page does not, because every page transition reloads.

Jump to heading Core implementation workflow

Jump to heading Step 1 — Declare the pages in one place

Both frameworks have moved toward declaring navigation explicitly rather than inferring it from a directory listing. Explicit declaration is worth the few extra lines, because it puts the page list where a reader can see it and lets shared setup run before any page does.

python
import streamlit as st

st.set_page_config(page_title="Spatial Ops", layout="wide")
bootstrap_state()                    # defined in Step 2, runs before any page body

pages = st.navigation([
    st.Page("pages/map_view.py",    title="Map",        icon=":material/map:"),
    st.Page("pages/table_view.py",  title="Table",      icon=":material/table:"),
    st.Page("pages/compare.py",     title="Compare",    icon=":material/compare:"),
])
pages.run()

Everything above pages.run() executes on every page, which makes it the right home for state initialisation, authentication and anything else that must be true regardless of where the analyst landed — including on a deep link that skips the map page entirely.

Jump to heading Step 2 — Give every shared key a default, once

The first script run of a session has no widgets and therefore no widget-written keys. A page that reads st.session_state.bbox before anything has written it raises, and because the raising line sits above the widget that would have created the key, the error repeats on every rerun. Initialise defensively in the shared bootstrap.

python
DEFAULTS = {
    "bbox":      (-0.51, 51.28, 0.33, 51.69),   # EPSG:4326, Greater London
    "epsg":      4326,
    "region":    "all",
    "selected":  [],                            # feature ids, not features
}

def bootstrap_state() -> None:
    """Idempotent: writes only what is missing, so it never clobbers a choice."""
    for key, value in DEFAULTS.items():
        st.session_state.setdefault(key, value)

Note what is not in DEFAULTS: no GeoDataFrame, no figure, no filtered subset. Session state holds identifiers and scalars — a bounding box, an EPSG code, a list of feature ids. The frame those ids refer to is fetched from the cache on whichever page needs it.

Jump to heading Step 3 — Load layers through one cached accessor

Every page that needs geometry calls the same function with the same arguments, so the second page to ask gets a cache hit rather than a second load.

python
import geopandas as gpd

@st.cache_data(ttl=3600, max_entries=12, show_spinner="Loading layer…")
def load_layer(region: str, epsg: int) -> gpd.GeoDataFrame:
    """One accessor, called from every page. The cache does the sharing."""
    gdf = gpd.read_parquet(f"data/{region}.parquet")
    if gdf.crs is None:
        gdf = gdf.set_crs("EPSG:4326")
    return gdf.to_crs(epsg=epsg)[["feature_id", "name", "category", "geometry"]]

# On any page:
layer = load_layer(st.session_state.region, st.session_state.epsg)

The arguments are exactly the session-state values that identify the layer, which is what makes the cache key stable across pages. Passing the frame itself between pages through session state would defeat this entirely — and would also pin one copy per session, which is the failure the previous section warned about.

Jump to heading Step 4 — Put navigation state in the URL as well

Session state survives navigation within a session; it does not survive a refresh, a shared link, or a browser restart. For anything an analyst might want to send to a colleague, mirror it into the query string, which costs a few lines and turns “here is what I am looking at” from a screenshot into a link.

python
def sync_query_params() -> None:
    """Session state is the source of truth; the URL is a durable mirror of it."""
    st.query_params.update({
        "region": st.session_state.region,
        "bbox":   ",".join(f"{v:.4f}" for v in st.session_state.bbox),
    })

def restore_from_query_params() -> None:
    """Run once, in bootstrap, before the defaults are applied."""
    params = st.query_params
    if "region" in params:
        st.session_state.region = params["region"]
    if "bbox" in params:
        try:
            st.session_state.bbox = tuple(float(v) for v in params["bbox"].split(","))
        except ValueError:
            pass                      # a malformed link falls back to the default

Round the coordinates on the way out. An unrounded bounding box produces a URL of sixty characters of noise that no one can read, changes on every micro-pan, and — as dynamic spatial filtering explains — defeats the quantised cache key it is meant to correspond to. Four decimals is about eleven metres and is more precision than a shared link needs.

The try around the parse is not decoration. Query strings are user input, they get truncated by chat clients and mangled by email, and a malformed one should land the analyst on the default view rather than on a traceback.

Jump to heading Step 5 — Reconcile the viewport on the page that owns it

Only the map page can move the viewport, but every page may need to know where it is. Have the map page write it back into session state after any change, and have the other pages treat it as read-only.

python
# pages/map_view.py — the one page that writes the viewport
result = st_folium(build_map(layer, st.session_state.bbox), height=560,
                   returned_objects=["bounds"])

if result and result.get("bounds"):
    b = result["bounds"]
    fresh = (round(b["_southWest"]["lng"], 4), round(b["_southWest"]["lat"], 4),
             round(b["_northEast"]["lng"], 4), round(b["_northEast"]["lat"], 4))
    if fresh != st.session_state.bbox:          # guard: identical bounds fire constantly
        st.session_state.bbox = fresh
        sync_query_params()
        st.rerun()

The equality guard is the same one described in syncing dropdown filters with map boundaries, and it matters more in a multi-page app rather than less: a rerun loop that merely wastes cycles on a single page will, on a page-switch, also re-run the destination page’s load.

What survives a page switch, and what is rebuiltAn analyst moves from the map page to the table page and back. Three horizontal bands track what happens to each kind of state. Session state — the bounding box, the region, the selection — persists straight through both transitions untouched, because it lives on the session rather than on the script. The cached layer also persists, held by its key rather than by the page that loaded it, so the table page's call to the same accessor with the same arguments is a hit and costs nothing. The page script and everything derived inside it — the folium map object, the filtered subset, the figure — is discarded and rebuilt on every transition, which is correct because each is cheap to reproduce from the two bands above it. The first load pays for the layer; the two after it do not.MAP → TABLE → MAPopen Mapswitch to Tableback to Mapsession statebbox · region · selection — untouched by either transitioncached layerloadheld by its key, not by the page — both later calls are hitspage scriptmap object, figuretable, subsetrebuiltDiscarding the third band is correct — each item in it is cheap to reproduce from the two above. The first load pays forthe layer; the two transitions after it do not. What a page transition should and should not costThe time from clicking a page link to the destination being usable is broken into its parts, for a well-arranged dashboard and a badly arranged one. Well arranged, the shared bootstrap runs in about two milliseconds because everything it reads is cached, the destination page reads its layer from a cache hit in about thirty, and rendering takes ninety — a transition of roughly a hundred and twenty milliseconds, which reads as instant. Badly arranged, the bootstrap performs an uncached permissions lookup of about two hundred milliseconds, the destination loads its layer from source because its accessor differs from the previous page's, and the same render follows — nearly three seconds, on every click. The render is identical in both; everything else is arrangement.CLICK TO USABLE, ON A PAGE TRANSITIONbootstrap · cached2 mslayer · cache hit30 msrender90 ms — the same in both casesbootstrap · uncached lookup200 ms, on every clicklayer · loaded from source2.6 s, becausethe accessor differedThe render is identical in both. Everything above and below it is arrangement, which is why a transition budget of'no data load' is worth holding to literally.

Jump to heading Advanced patterns

Panel’s equivalent. Panel serves each template from its own function, and pn.state.cache is process-global rather than per-session — the opposite default from Streamlit’s session state. Per-session values go in pn.state.session_info or in a parameterised class instance created per session; shared layers go in pn.state.cache or behind pn.cache, exactly as the Folium versus ipyleaflet comparison describes. Getting these the wrong way round produces the two failures in the diagram above, and produces them in Panel more easily than in Streamlit because the global cache is the more convenient of the two to reach for.

Deep links that skip the landing page. Once the viewport is in the query string, an analyst can bookmark a page mid-workflow, which means every page must be able to bootstrap itself from nothing but the URL. This is a good constraint: it forces the shared bootstrap to be genuinely complete, and it makes each page independently testable. It also means the restore has to run before any page body reads a key, which is the reason Step 1 puts the bootstrap above pages.run().

Keeping a selection meaningful across pages. A list of feature identifiers survives a page switch cheaply. A list of row positions does not, because the next page may load a differently filtered frame and position seventeen will point at something else entirely. Store identifiers, resolve them to rows on whichever page needs them, and treat a missing identifier as a selection that has been filtered out rather than as an error.

Jump to heading Verification and testing

The behaviours worth asserting are the ones a manual click-through misses.

python
# 1. The bootstrap is idempotent — running it twice never clobbers a choice.
st.session_state.region = "camden"
bootstrap_state()
assert st.session_state.region == "camden", "bootstrap overwrote a live value"

# 2. The layer accessor is shared, not per page.
a = load_layer("camden", 4326)
b = load_layer("camden", 4326)
assert a is not None and len(a) == len(b)
assert load_layer.clear is not None      # it really is the cached wrapper

# 3. A malformed deep link degrades to the default rather than raising.
st.query_params["bbox"] = "not,a,bbox"
restore_from_query_params()
assert len(st.session_state.bbox) == 4, "a bad link should fall back, not crash"

Beyond the assertions, walk the application with the browser devtools network panel open and switch pages twice. The second visit to a page should issue no data request at all. If it does, the accessor is being called with arguments that differ between pages — usually an EPSG code passed as an integer on one page and a string on another, which produces two cache entries that hold identical frames.

Jump to heading Troubleshooting

KeyError on a session-state key, on the first load only. The read is above the widget that creates the key, and the bootstrap either does not cover it or runs after pages.run(). Add the key to DEFAULTS.

The layer reloads on every page switch. The accessor’s arguments differ between pages, or the accessor is defined inside a page module so each page gets its own decorated function with its own cache. Define it once in a shared module and import it.

The viewport resets when navigating back. The map page is initialising the map from a constant rather than from session state. The map’s starting bounds must be read from state on every render, not only on the first.

Memory climbs with the number of connected analysts. A frame is in session state. Grep for assignments of GeoDataFrame values into st.session_state — there should be none, and the fix is to store the arguments that identify it instead.

The URL updates but the page does not. st.query_params.update does not trigger a rerun on its own. Either accept that the URL is a mirror updated on the next natural rerun, or call st.rerun() explicitly after changing state that the page renders from.

Jump to heading Performance considerations

Page transitions are the moment a dashboard feels fast or slow, because they are the only interaction where the user expects a whole new screen and will therefore forgive a short delay — but only a short one. The budget worth holding to is that no page transition triggers a data load: everything a destination page needs should already be in a cache keyed by values that survived the transition.

Bound the shared cache with both a TTL and a max_entries, and size the entry count against pages rather than sessions. A four-page dashboard where each page can request a layer for any of twelve regions has at most twelve distinct entries, not forty-eight — provided every page routes through the same accessor. The moment a page grows its own loader, that number multiplies, which is a good reason to treat “one accessor per layer” as an architectural rule rather than a convention.

Finally, keep the shared bootstrap genuinely cheap. It runs on every page transition, and anything expensive in it — a database round trip for a lookup table, a permissions check that is not cached, a directory scan — is paid on every click, on top of whatever the destination page does. Cache the lookups it needs with the same discipline you would apply to a layer, and the transition stays at the few tens of milliseconds where it belongs.

Back to Core Dashboard Architecture & State Management.