Preserving the Map Viewport Across Streamlit Pages
Session state already survives a page switch — the map resets because the page rebuilds it from a constant. Read the bounding box from state on every render, write it back only from the map page behind an equality guard, and mirror it into the query string so a refresh survives too.
Jump to heading Why this matters
The complaint arrives in the same words every time: “I pan to my area, click over to the table, come back, and I’m looking at the whole country again.” It is the single most reported defect in multi-page spatial dashboards, and it is almost never a state-persistence bug. Streamlit keeps st.session_state for the life of the session, across every page in it. What does not survive is the folium.Map object, the pydeck.Deck, and every local variable that built them — and if the code that builds them reads a hard-coded centre and zoom, the view resets no matter how faithfully the state was preserved.
Fixing it properly means separating three things that are easy to conflate: the viewport value, which is small and belongs in session state; the map object, which is derived and should be rebuilt from that value on every render; and the viewport event, which only the map page produces and which needs a guard so it does not feed itself. This guide wires all three, and then adds the URL so the view survives a browser refresh as well.
Jump to heading Prerequisites
streamlit>=1.36,streamlit-folium>=0.20orpydeck, and the multi-page setup from Multi-Page App Navigation.- A shared bootstrap that runs above
pages.run(). Everything below depends on it.
Jump to heading Step-by-step solution
Jump to heading Step 1 — Give the viewport a default, above every page
# app.py — runs before any page body, including on a deep link
import streamlit as st
DEFAULT_BBOX = (-0.5103, 51.2868, 0.3340, 51.6919) # EPSG:4326
def bootstrap() -> None:
st.session_state.setdefault("bbox", DEFAULT_BBOX)
st.session_state.setdefault("epsg", 4326)
st.set_page_config(layout="wide")
bootstrap()
st.navigation([st.Page("pages/map_view.py"), st.Page("pages/table_view.py")]).run()
setdefault rather than an assignment is what makes this safe to run on every page: it writes only when the key is absent, so it can never overwrite a viewport the analyst just panned to.
Jump to heading Step 2 — Build the map from state, never from a constant
# pages/map_view.py
import folium
from streamlit_folium import st_folium
def build_map(gdf, bbox):
m = folium.Map(tiles="CartoDB positron")
m.fit_bounds([[bbox[1], bbox[0]], [bbox[3], bbox[2]]]) # (s,w) then (n,e)
folium.GeoJson(gdf.__geo_interface__, name="layer").add_to(m)
return m
layer = load_layer(st.session_state.epsg) # cached, shared across pages
result = st_folium(build_map(layer, st.session_state.bbox),
height=560, width="100%",
returned_objects=["bounds"])
fit_bounds rather than location and zoom_start is deliberate. A bounding box is what the other pages and the URL carry, and converting it to a centre and zoom loses information — two different extents can share a centre and a rounded zoom level, so a round trip through them does not return the same view.
Note the argument order Leaflet uses: fit_bounds takes south-west then north-east as [lat, lon] pairs, while the bounding box is stored in the GeoJSON convention of west, south, east, north. Getting this wrong produces a map somewhere off the coast of West Africa, which is the traditional symptom of swapped latitude and longitude.
Jump to heading Step 3 — Write the viewport back, behind an equality guard
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:
st.session_state.bbox = fresh # store FIRST
st.rerun() # then rerun
Two details carry the whole behaviour. Rounding to four decimals — about eleven metres — collapses the micro-movements Leaflet reports during a hover into a single value, so a stationary pointer stops producing “new” bounds. And storing before the rerun means the rerun’s own bounds event compares equal and terminates the cycle; storing after would leave the guard permanently one step behind and loop forever.
Jump to heading Step 4 — Mirror it into the URL
def sync_url() -> None:
st.query_params["bbox"] = ",".join(f"{v:.4f}" for v in st.session_state.bbox)
def restore_url() -> None:
raw = st.query_params.get("bbox")
if not raw:
return
try:
values = tuple(float(v) for v in raw.split(","))
except ValueError:
return # a mangled link → the default
if len(values) == 4 and -180 <= values[0] < values[2] <= 180:
st.session_state.bbox = values
Call restore_url() in the bootstrap before setdefault, and sync_url() wherever the viewport changes. The validation is not decoration: query strings get truncated by chat clients and rewritten by mail scanners, and a malformed one should land on the default rather than on a traceback — or, worse, on a bounding box with west greater than east, which produces an empty map and no error at all.
Jump to heading Verification
Three checks, none of which a manual click-through reliably catches.
# 1. The bootstrap never clobbers a live viewport.
st.session_state.bbox = (-0.20, 51.45, -0.05, 51.55)
bootstrap()
assert st.session_state.bbox == (-0.20, 51.45, -0.05, 51.55)
# 2. A mangled link degrades to the default rather than raising.
st.query_params["bbox"] = "1,2,broken"
restore_url()
assert len(st.session_state.bbox) == 4
# 3. An inverted box is rejected, not stored.
st.query_params["bbox"] = "0.33,51.28,-0.51,51.69" # west > east
before = st.session_state.bbox
restore_url()
assert st.session_state.bbox == before, "an inverted bbox was accepted"
Then walk it: pan, switch page, switch back, and confirm the extent. Refresh the browser and confirm it again. Copy the URL into a private window and confirm a third time. Each of the three exercises a different mechanism, and it is common for two to work while the third does not.
Jump to heading Edge cases and gotchas
- The first bounds event arrives before the map has settled. Some component versions emit the default extent once on mount, which will overwrite a restored deep-link view. Ignore bounds events on the very first render of a session by keeping a
viewport_readyflag in state. - Two map pages, one bounding box. If a second page also renders a map, decide which one owns writes. Two writers with independent guards will fight, each seeing the other’s value as a change.
- Zoom is not recoverable from bounds alone. A bounding box fixes the extent, not the zoom level, and a component may choose a zoom whose extent is slightly larger. This is usually fine and occasionally not — if exact zoom matters, store it alongside the box rather than deriving it.
st.query_paramsdoes not trigger a rerun. Writing it updates the URL for the next natural rerun. Do not add an explicit rerun just to refresh the URL, or you have built the loop the guard was preventing.
Jump to heading FAQ
Why not store the folium map object in session state?
Because it is derived state, and storing it makes it the thing that has to be kept in sync with the data, the filters and the theme rather than simply being rebuilt from them. It is also large. Rebuilding a map from a cached layer and four floats takes milliseconds; keeping a stale one correct takes forever.
My map jumps slightly on every page return. Why?
fit_bounds picks the nearest zoom level that contains the box, which is usually a hair wider than the extent you left. Each round trip through bounds and back can therefore drift outward by a fraction of a zoom step. Store the zoom explicitly alongside the bounding box if the drift is visible, and apply both.
Should the table page also update the viewport?
Only if selecting a row is meant to move the map, and even then it should write the bounding box rather than manipulate a map object it does not own. Keeping exactly one writer per piece of state is what makes the equality guard sufficient.
Back to Multi-Page App Navigation.