Skip to content

feat: allow external URLs in st.Page for navigation sidebar #13691

Merged
sfc-gh-bnisco merged 27 commits intostreamlit:developfrom
t0k0shi:feature/external-links-in-navigation
Mar 5, 2026
Merged

feat: allow external URLs in st.Page for navigation sidebar #13691
sfc-gh-bnisco merged 27 commits intostreamlit:developfrom
t0k0shi:feature/external-links-in-navigation

Conversation

@t0k0shi
Copy link
Copy Markdown
Contributor

@t0k0shi t0k0shi commented Jan 25, 2026

Add support for external web links in st.navigation, allowing users to include external URLs alongside internal pages in both sidebar and top navigation modes.

This implements the feature requested in #9025.

Changes

Backend (lib/streamlit/navigation/page.py)

  • Detect HTTP/HTTPS URLs using existing url_util.is_url()
  • Handle external URLs in StreamlitPage.init with title validation and URL path sanitization
  • Add is_external and external_url properties
  • Skip code execution for external links in run()
  • Use explicit None check for url_path to correctly reject empty strings

Backend (lib/streamlit/commands/execution_control.py, lib/streamlit/elements/widgets/button.py)

  • st.switch_page: raise StreamlitAPIException for external pages (internal navigation only)
  • st.page_link: render external StreamlitPage objects as external links

Protobuf (proto/streamlit/proto/AppPage.proto)

  • Adds optional external_url field

Frontend

  • Update SidebarNavLink to handle external links with target="_blank" and rel="noopener noreferrer"
  • Update SidebarNav, TopNav, TopNavSection to pass external link info
  • Pass props explicitly in TopNavSection instead of spreading proto fields

Tests

  • Add unit tests for external URL support in page_test.py (URL detection, title validation, url_path sanitization, edge cases)
  • Add is_external=False to existing switch_page test mocks
  • Add test for st.switch_page rejecting external pages
  • Add tests for st.page_link with external StreamlitPage objects
  • Add unit tests for external link click behavior in SidebarNav.test.tsx and TopNav.test.tsx
  • Add 8 E2E tests for external links in sidebar and top navigation
  • Add proto compatibility test fields

Example Usage

import streamlit as st

def home():
st.title("Home")

pages = [
st.Page(home, title="Home", icon="🏠", default=True),
st.Page("https://docs.streamlit.io", title="Docs", icon="📚"),
st.Page("https://github.com/streamlit/streamlit", title="GitHub", icon="🐙"),
]
pg = st.navigation(pages)
pg.run()

External links:

  • Require a title parameter (cannot be inferred from URL)
  • Cannot be set as the default page
  • Open in a new tab (target="_blank")
  • st.switch_page raises an error (internal navigation only)
  • st.page_link renders them as external links

Demo

naviside.mp4

External links in the sidebar open in a new tab. Internal pages navigate normally.

Closes #9025

@t0k0shi t0k0shi requested a review from a team as a code owner January 25, 2026 12:51
@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Jan 25, 2026

Snyk checks have passed. No issues have been found so far.

Status Scanner Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@github-actions
Copy link
Copy Markdown
Contributor

Thanks for contributing to Streamlit! 🎈

Please make sure you have read our Contributing Guide. You can find additional information about Streamlit development in the wiki.

The review process:

  1. Initial triage: A maintainer will apply labels, approve CI to run, and trigger AI-assisted reviews. Your PR may be flagged with status:needs-product-approval if the feature requires product team sign-off.

  2. Code review: A core maintainer will start reviewing your PR once:

    • It is marked as 'ready for review', not 'draft'
    • It has status:product-approved (or doesn't need it)
    • All CI checks pass
    • All AI review comments are addressed

We're receiving many contributions and have limited review bandwidth — please expect some delay. We appreciate your patience! 🙏

@lukasmasuch lukasmasuch added change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users status:needs-product-approval PR requires product approval before merging labels Jan 25, 2026
@lukasmasuch lukasmasuch requested a review from Copilot January 25, 2026 17:42
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds support for external web links in st.Page for navigation menus, allowing users to include external URLs alongside internal pages. The implementation addresses feature request #9025 by enabling external links that open in new tabs when clicked.

Changes:

  • Added external URL detection and validation in st.Page constructor with proper error handling for missing titles and invalid default page configuration
  • Extended the AppPage protobuf message with is_external and external_url fields for client-server communication
  • Updated frontend navigation components (SidebarNavLink, TopNav, TopNavSection, SidebarNav) to render external links with target="_blank" and proper security attributes

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
proto/streamlit/proto/AppPage.proto Added is_external and external_url fields to AppPage message for backward-compatible external URL support
lib/streamlit/navigation/page.py Implemented _is_url() helper, external URL validation, and new properties is_external and external_url; modified run() to skip execution for external pages
lib/streamlit/commands/navigation.py Updated navigation command to set protobuf fields for external URL pages
lib/tests/streamlit/navigation/page_test.py Added comprehensive test suite (11 tests) covering external URL functionality including edge cases
frontend/app/src/components/Navigation/SidebarNavLink.tsx Added external link support with target="_blank", rel="noopener noreferrer", and conditional onClick handling
frontend/app/src/components/Navigation/TopNav.tsx Updated to detect and handle external pages by checking isExternal flag and early-returning from onClick
frontend/app/src/components/Navigation/TopNavSection.tsx Modified dropdown items to support external links with proper click handling and popover closing
frontend/app/src/components/Navigation/SidebarNav.tsx Added external link detection and handling to sidebar navigation links

Comment on lines +81 to +86
href={isExternal && externalUrl ? externalUrl : pageUrl}
onClick={isExternal ? undefined : onClick}
aria-current={isActive ? "page" : undefined}
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SidebarNavLink component now supports external links with new props isExternal and externalUrl, but there are no tests covering this new functionality. The existing test file (SidebarNavLink.test.tsx) has comprehensive coverage for other features.

Add tests to verify:

  1. External links render with target="_blank" and rel="noopener noreferrer" attributes
  2. External links use externalUrl as the href when isExternal is true
  3. External links do not call onClick handler (it should be undefined)
  4. Internal links continue to work as before (negative assertion: verify target attribute is not present when isExternal is false)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

href={pageUrl}
onClick={onClick}
href={isExternal && externalUrl ? externalUrl : pageUrl}
onClick={isExternal ? undefined : onClick}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TopNav dropdown popover doesn't close for external links

Medium Severity

When clicking an external link in the TopNavSection dropdown popover, the popover remains open. In TopNavSection, the handleClick function includes setOpen(false) to close the popover for external links. However, SidebarNavLink sets onClick={isExternal ? undefined : onClick}, which means for external links the handler is never attached to the DOM element. The setOpen(false) call intended to close the popover is never reached.

Additional Locations (1)

Fix in Cursor Fix in Web

p.is_default = page._default
p.section_header = section_header
p.url_pathname = page.url_path
# External URL support (Issue #9025)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

External URL can become default page implicitly

High Severity

When no page explicitly has default=True, _navigation() selects the first page as default by directly setting _default = True on the instance. This bypasses the constructor validation that prevents external URLs from being default pages. If an external URL is first in the pages list, it becomes the default, and calling run() returns immediately without rendering any content, leaving the app with a blank main content area.

Additional Locations (1)

Fix in Cursor Fix in Web

@t0k0shi t0k0shi force-pushed the feature/external-links-in-navigation branch from 8697ec7 to 01f7b2e Compare January 26, 2026 10:06
@jrieke
Copy link
Copy Markdown
Collaborator

jrieke commented Jan 27, 2026

Behavior and API sound good! Let me know once you fixed the most important issues above and I can give this a try.

should we add a visual indicator (e.g., external link icon) next to external links in the navigation menu? I can add this if
desired.

Yeah I think that would be cool! Can you try adding a small material icon on the right side of it, and post a screenshot of what it looks like? Then we can decide based on that.

@t0k0shi
Copy link
Copy Markdown
Contributor Author

t0k0shi commented Jan 27, 2026

@jrieke Thank you for the review!

I've addressed the feedback and added a visual indicator for external links:

  • Added a small launch icon (:material/launch:) to the right side of external links
  • Added tests for the icon rendering

Screenshot:
launch

Ready for your review when you have a chance!

@jrieke
Copy link
Copy Markdown
Collaborator

jrieke commented Jan 31, 2026

Hm okay I think the icon actually looks a bit weird, given that page icons are on the left side. I'd say we maybe we just leave this out and the dev can put an icon themselves on the left side if they want to show an indicator of this being an external page.

Did you already work on addressing the comments from Cursor above? If yes, happy to try it out locally.

@t0k0shi
Copy link
Copy Markdown
Contributor Author

t0k0shi commented Jan 31, 2026

Hm okay I think the icon actually looks a bit weird, given that page icons are on the left side. I'd say we maybe we just leave this out and the dev can put an icon themselves on the left side if they want to show an indicator of this being an external page.

Thanks for the feedback! The external link icon (launch icon) is placed on the right side, following the common UI pattern where left icons indicate content type and right icons indicate actions/behavior (similar to Material Design guidelines). Could you clarify what feels off — is it having two icons per row, or something else about the styling? Happy to adjust.

Did you already work on addressing the comments from Cursor above? If yes, happy to try it out locally.

Yes, both Cursor comments have been addressed

  1. Popover now closes when clicking external links in the dropdown
  2. External URLs are skipped when selecting an implicit default page

@t0k0shi t0k0shi closed this Jan 31, 2026
@t0k0shi t0k0shi reopened this Jan 31, 2026
@t0k0shi
Copy link
Copy Markdown
Contributor Author

t0k0shi commented Jan 31, 2026

@jrieke
Thanks for the feedback on the external link icon! I understand the concern about it looking odd with page icons on the left.

How about aligning the nav items in a three-column layout?

① Left icon (fixed width) ② Link text (flex) ③ Right icon (launch)
🏠 Home
📄 About
📚 Docs
(empty) GitHub

This way:

  • The left icon area always reserves space, so link text stays aligned even when no icon is set
  • The launch icon sits on the right, following the common UI convention (left = content type, right = action/behavior)
  • Items without a launch icon just have empty space on the right, keeping everything clean

I prototyped this with a small change — just fixed the width of StyledSidebarNavIcon and used margin-left: auto on the launch icon. All existing tests pass.

What do you think? Happy to push this if you'd like to see it in action.

@t0k0shi t0k0shi force-pushed the feature/external-links-in-navigation branch from 40d5f05 to d92949e Compare February 4, 2026 01:02
@jrieke
Copy link
Copy Markdown
Collaborator

jrieke commented Feb 13, 2026

Appreciate the effort but let's just remove the icon. I think altering the layout adds too much complexity for a small feature.

@t0k0shi
Copy link
Copy Markdown
Contributor Author

t0k0shi commented Feb 15, 2026

@jrieke Thanks for the feedback! I've removed the launch icon — external links now use the same layout as internal pages (icon + text only).

Both Cursor comments are also addressed:

  1. Popover closes when clicking external links in the dropdown
  2. External URLs are skipped when selecting an implicit default page

Ready for re-review when you have a chance.

@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR adds support for external web links (http/https URLs) in st.Page and st.navigation, allowing users to include external URLs alongside internal pages in the navigation menu. External links open in a new browser tab via target="_blank". The changes span the full stack: Python backend (page.py, navigation.py), protobuf (AppPage.proto), and frontend components (SidebarNavLink, SidebarNav, TopNav, TopNavSection).

Code Quality

The code is generally well-structured and follows existing patterns. A few observations:

  1. Issue tracker references in production code/proto comments: Comments like // External URL support (Issue #9025) appear in SidebarNavLink.tsx (line 37), TopNav.tsx, TopNavSection.tsx, navigation.py (line 412), and AppPage.proto (line 37). Per project conventions, comments should describe what or why, not reference issue numbers. Issue linking belongs in commit messages and PR descriptions.

  2. _sanitize_url_path in page.py (lines 31-45): This is a reasonable addition, properly prefixed with _ as a module-private function. However, the regex [&#?/\\:*\"<>|'] does not cover all URL-unsafe characters (e.g., %, +, =, @, ;). Consider using a more established URL-safe character allowlist approach (e.g., only keep [a-z0-9_]) for robustness.

  3. _page type widening in page.py (line 301): The type of self._page was widened from Path | Callable[[], None] to Path | Callable[[], None] | None. While the code handles None correctly today (external pages return early from run()), this loosens the type contract. Consider using a separate sentinel or Union-discriminated approach to make this more explicit and type-safe.

  4. TopNavSection.tsx (line 109): The {...item} spread on SidebarNavLink passes all IAppPage props to the component. While it works, it may inadvertently pass unknown props to the DOM. Since isExternal and externalUrl are now explicitly passed (lines 120-121), the spread is redundant for those fields.

Test Coverage

Python unit tests (page_test.py): Good coverage of StreamlitPage external URL behavior — 14 new tests covering creation, validation, sanitization, edge cases, and run() no-op behavior.

Frontend unit tests (SidebarNavLink.test.tsx): Good coverage of the SidebarNavLink component — 7 new tests covering target="_blank", rel, href resolution, click handling, and top-nav integration.

Significant gaps:

  1. No tests for navigation.py changes: The logic in _navigation() that (a) skips external pages when selecting a default, and (b) raises an error when all pages are external (lines 343-354) has no corresponding tests in navigation_test.py. These are critical code paths.

  2. No E2E tests: For a feature that fundamentally changes navigation behavior (links opening in new tabs, external URLs displayed in sidebar/top nav), E2E tests are important to verify full-stack behavior. Per the project's testing strategy and e2e_playwright/AGENTS.md, new elements or significant changes to existing ones should have E2E coverage.

  3. No tests for st.switch_page / st.page_link interaction: See "Backwards Compatibility" section — these interactions are untested and potentially buggy.

  4. No tests for SidebarNav, TopNav, or TopNavSection with external pages: Only SidebarNavLink has new tests. The click handlers in the parent components (which handle e.preventDefault() skipping for external links) are not tested.

Backwards Compatibility

Protobuf: Adding fields 8 (is_external) and 9 (external_url) to AppPage.proto is backward compatible. Old clients will ignore the new fields; old servers will send default values (false / "").

Critical interaction issues:

  1. st.switch_page with external StreamlitPage (execution_control.py, line 276-277): If a user passes an external StreamlitPage to st.switch_page(page), the code uses page._script_hash and attempts an internal page navigation/rerun. It does not check page.is_external and does not raise a helpful error. The user would experience confusing behavior (the app reruns with no page content, or falls back to default).

  2. st.page_link with external StreamlitPage (button.py, lines 1341-1349): When page_link receives a StreamlitPage, it sets page_script_hash and page (url_path) but does not set external = True. The external flag is only set when the page argument is a raw URL string (line 1360). This means st.page_link(external_page) would produce a broken internal link instead of an external one.

  3. URL routing to external pages: External pages have a url_path (derived from title) and a _script_hash. If a user navigates to the external page's URL path in the browser (e.g., /docs), the _navigation function will select and return that external page. Calling .run() on it is a no-op, so the user sees entrypoint content but no page body — a confusing blank-page experience.

Security & Risk

  • URL scheme validation: Uses the existing is_url() utility which checks for http/https schemes, preventing javascript: or other dangerous URI schemes. Good.
  • target="_blank" with rel="noopener noreferrer": Correctly applied to prevent reverse tabnapping. Good.
  • No URL content validation: The external URL is passed directly to the frontend href attribute. While scheme validation prevents script injection via javascript: URIs, there's no validation of the URL structure itself (e.g., extremely long URLs, URLs with encoded payloads). This is a low risk since browsers handle this, but worth noting.
  • Regression risk: The URL routing issue (point 3 above) could cause regressions if external page url_paths collide with internal page url_paths.

Accessibility

  1. Missing "opens in new tab" indicator: External links open via target="_blank" but provide no visual or accessible indication to the user. Per WCAG 2.1 SC 3.2.5 (Change on Request) and common accessibility best practices, links that open in a new window/tab should inform users. Consider:

    • Adding an external link icon (e.g., :material/open_in_new:) automatically or via aria attributes.
    • Adding aria-label text like "${pageName} (opens in new tab)" to the anchor element.
    • The PR description mentions this as an open question — it should be resolved before merge.
  2. aria-current="page" on external links: If an external page's hash somehow matches the current page hash, it would receive aria-current="page", which is semantically incorrect since external links are never the "current" page in the app.

Recommendations

  1. Handle st.switch_page and st.page_link for external pages: At minimum, st.switch_page should raise a StreamlitAPIException if passed an external page (e.g., "Cannot switch to an external URL page. Use st.page_link or the navigation menu instead."). st.page_link should detect page.is_external and set external = True / page = page.external_url on the proto.

  2. Prevent URL routing to external pages: External pages should not be matchable via URL routing. Consider either (a) not including external pages in pagehash_to_pageinfo, or (b) handling the case in _navigation where page_to_return is external by falling back to the default page.

  3. Add tests for _navigation() changes: Add tests to navigation_test.py for:

    • Mixed internal + external pages (default auto-selects first internal page).
    • All-external pages raises error.
    • Protobuf message correctly includes is_external and external_url fields.
  4. Add E2E tests: Add at least one E2E test verifying that external links in sidebar/top navigation render correctly with target="_blank" and the correct href.

  5. Add accessibility indicators: Add a visual and/or ARIA indicator that external links open in a new tab. At minimum, append "(opens in new tab)" to the aria-label.

  6. Remove issue tracker references from code comments: Replace // External URL support (Issue #9025) with descriptive comments, or remove if the code is self-explanatory.

  7. Consider excluding external pages from isActive comparison: In SidebarNav.tsx (line 283) and TopNav.tsx (line 93), external pages should never be marked active. Consider const isActive = !isExternal && page.pageScriptHash === currentPageScriptHash.

Verdict

CHANGES REQUESTED: The feature concept is solid, but there are critical interaction bugs with st.switch_page and st.page_link when receiving external StreamlitPage objects, a URL routing issue where navigating to an external page's path results in a blank page, missing tests for the navigation command changes, no E2E tests, and accessibility concerns around missing "opens in new tab" indicators. These issues need to be addressed before this PR is ready for merge.


This is an automated AI review by opus-4.6-thinking.

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Feb 16, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

t0k0shi added 6 commits March 3, 2026 09:07
Source fixes:
- page.py: fix mypy no-redef errors by using conditional assignments
- navigation.py: remove redundant cast calls (mypy redundant-cast)
- execution_control.py: reject st.switch_page for external URL pages
- button.py: support external StreamlitPage in st.page_link

Test additions:
- execution_control_test.py: add is_external=False to existing mocks,
  add test for switch_page with external page
- page_link_test.py: add tests for external StreamlitPage support
- page_test.py: fix test assertions for updated page behavior
- proto_compatibility_test.py: add new proto fields to known list
MagicMock(spec=StreamlitPage) creates mock attributes that are truthy
by default. Without explicitly setting is_external=False, the test
enters the external page code path and fails with TypeError.
…ation

- Fix RUF043 lint error: use raw string with escaped metacharacters in
  pytest.raises match parameter
- Fix url_path="" vs None inconsistency: use explicit None check instead
  of falsy-or to distinguish "not provided" from "explicitly empty"
- Add test for explicit empty url_path on external URL pages
- Add E2E tests for external links in sidebar and top navigation (8 tests)
- Remove {…item} spread in TopNavSection to prevent proto field DOM leakage
- Fix handleClick return type from boolean to void to match prop interface
- Remove Issue streamlit#9025 reference comments from 5 files
Reformat long lines in page.py and execution_control_test.py
to satisfy ruff line-length rules.
…ormalization

Address maintainer review feedback (sfc-gh-bnisco):
- Prevent external pages from being selected as runnable target on direct URL access
- Unify url_path normalization between external and internal pages
- Add regression tests for both fixes
@t0k0shi t0k0shi force-pushed the feature/external-links-in-navigation branch from 7434f83 to 33c21d1 Compare March 3, 2026 09:07
sfc-gh-bnisco and others added 9 commits March 4, 2026 12:55
Replace external navigation flags with an internal/external destination oneof to simplify AppPage semantics and avoid invalid state combinations. Also centralize frontend external-page detection helpers and remove unused open behavior metadata.

Made-with: Cursor
…l docs

Treat pages as external based on destination oneof presence instead of URL truthiness so malformed payloads do not get misclassified as internal navigation targets. Clarify st.Page and st.switch_page docs to document that hidden external pages can only be opened via st.page_link.

Made-with: Cursor
- Add visually-hidden "(opens in new tab)" text for screen readers
- Guard isActive so external pages never show as active
- Use nullish coalescing (??) in getExternalPageUrl for precision
- Add tests for external/internal url_path collision detection

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Remove redundant comments, consolidate repetitive tests using
parameterized expansion, and simplify conditional prop spreading
in SidebarNavLink. Streamline _set_page_destination with direct
None-check instead of redundant is_external guard.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Cover external link click handling in the TopNavSection popover dropdown,
verifying that handlePageChange is not called for external links, is
called for internal links, and that external links render with correct
href/target/rel attributes while internal links do not.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Verify that dangerous URL schemes (javascript:, vbscript:, data:) are
rejected by st.Page and that hidden external pages are excluded from
both SidebarNav and TopNav rendering.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 4, 2026

Consolidated Code Review

Summary

This PR adds first-class support for external web links (http/https URLs) in st.navigation, allowing users to include external URLs alongside internal pages in both sidebar and top navigation modes. External links open in a new browser tab (target="_blank" with rel="noopener noreferrer"). The change spans the full stack: protobuf definitions (AppPage oneof destination), Python backend (st.Page, st.navigation, st.switch_page, st.page_link), and React frontend (sidebar nav, top nav, styled components). It includes comprehensive Python unit tests, frontend tests, and E2E tests.

Both reviewers approved this PR. No critical or blocking issues were identified.

Code Quality

Reviewers agree: The implementation is clean, well-structured, and follows existing patterns consistently across the stack.

Key strengths noted by both:

  • Good use of helper abstractions (isExternalPage, getExternalPageUrl, _set_page_destination) reused across components.
  • Clean separation of external vs. internal paths in StreamlitPage.__init__ via early-return pattern.
  • Correct use of the protobuf oneof pattern with SetInParent() for InternalDestination.
  • Removal of {...item} spread in TopNavSection.tsx in favor of explicit props — safer and more maintainable.

Minor suggestions (non-blocking):

  1. (Both reviewers) The class-level visibility attribute docstring in lib/streamlit/navigation/page.py (~line 234-239) still says hidden pages remain accessible via URL/st.switch_page, which is no longer true for external pages. Should be updated.
  2. (opus-4.6-thinking) The self._page type annotation is only declared in the external branch (line 291). Declaring it once at class level or at the top of __init__ would be cleaner:
    self._page: Path | Callable[[], None] | None = None

Test Coverage

Reviewers agree: Coverage is thorough, well-structured, and appropriately layered.

  • Python unit tests: ~22+ new tests covering URL detection, title validation, url_path sanitization, edge cases, external page rejection in st.switch_page, st.page_link with external pages, proto field serialization, and fallback behavior.
  • Frontend unit tests: Comprehensive coverage across SidebarNavLink, SidebarNav, TopNav, TopNavSection, and utility functions — including anti-regression checks (e.g., onPageChange not called for external links).
  • E2E tests: 3 tests covering sidebar attributes, sidebar internal navigation, and top nav attributes + navigation.

Minor gap (opus-4.6-thinking): No test for st.page_link with a hidden external StreamlitPage. The docs state hidden external pages "can only be opened via st.page_link" — a test verifying this would close the loop on documented behavior.

Backwards Compatibility

Reviewers agree: No breaking changes. All changes are purely additive.

  • Protobuf adds new fields (8, 9) via oneof — old clients ignore unknown fields; unset destination correctly treated as internal.
  • st.Page continues to accept the same parameter signature; external URLs are a new category of str inputs.
  • Frontend props (isExternal, externalUrl) are optional with correct falsy defaults.

Security & Risk

Reviewers agree: Security posture is strong.

  • External URL schemes restricted to http/https via is_url()javascript:, vbscript:, and data: schemes explicitly tested as rejected.
  • rel="noopener noreferrer" correctly applied to all external links.
  • Direct URL access to external pages prevented server-side.
  • st.switch_page rejects external pages with a clear error message.
  • The backend never fetches external URLs — all navigation is client-side metadata only.

No security issues found.

External Test Recommendation

Reviewers disagree:

  • gpt-5.3-codex-high: Yes (Medium confidence) — triggered categories: routing/URL behavior, cross-origin behavior.
  • opus-4.6-thinking: No (High confidence) — tests are inherently local-only due to app script dependency.

Resolution: I agree with opus-4.6-thinking's assessment. The E2E tests verify DOM attributes and local navigation behavior using a specific app script structure. They never actually navigate to external URLs. The tests cannot run against an arbitrary externally hosted app, making @pytest.mark.external_test inapplicable. No external test markers needed.

Accessibility

Reviewers agree: Good accessibility practices throughout.

  • Screen reader announcement via StyledVisuallyHidden with "(opens in new tab)" text — standard a11y pattern.
  • aria-current="page" correctly suppressed for external links.
  • Semantic <a> elements with proper href, target, and rel attributes.

Enhancement suggestion (opus-4.6-thinking): No visual indicator (icon) distinguishes external links for sighted users. A follow-up could add a small external-link icon (e.g., :material/open_in_new:) for visual parity with the screen reader text.

E2E External URLs

Reviewers disagree:

  • gpt-5.3-codex-high: Recommends replacing third-party URLs (https://docs.streamlit.io, https://streamlit.io) with test-stable/local assets per E2E guidance.
  • opus-4.6-thinking: Notes these URLs are only used as metadata strings in st.Page — never navigated to by test assertions (only attribute checks) — so they don't violate the "no external URLs" guideline.

Resolution: opus-4.6-thinking's analysis is correct. The URLs are string literals checked via attribute assertions, not HTTP requests. No network dependency is introduced. The current approach is acceptable, though using placeholder URLs (e.g., https://example.com) could further reduce any perception of external dependency.

Recommendations

  1. Update stale docstring in lib/streamlit/navigation/page.py (~line 234-239) to reflect that hidden external pages are not URL-accessible or switchable via st.switch_page. (Both reviewers)
  2. Consider declaring self._page type once at class level or top of __init__ for clarity. (Non-blocking)
  3. Add a test for st.page_link with a hidden external page to verify the documented behavior. (Non-blocking)
  4. Consider a visual external link indicator (e.g., an icon) in a follow-up to provide parity with the screen reader text for sighted users. (UX enhancement, non-blocking)
  5. Minor E2E consolidation opportunity: The sidebar attributes and sidebar navigation tests could potentially be merged into a single scenario to reduce one browser load. (Non-blocking)

Verdict

APPROVED — Both reviewers approved. The feature is implemented coherently across backend/frontend/proto layers with comprehensive test coverage, proper security safeguards, good accessibility practices, and fully backward-compatible changes. All recommendations are minor, non-blocking enhancements suitable for follow-up work.


Consolidated review by opus-4.6-thinking. Individual reviews from 2/2 expected models received (gpt-5.3-codex-high, opus-4.6-thinking). No models failed to complete review.


📋 Review by `gpt-5.3-codex-high`

Summary

This PR adds first-class external URL support to st.Page within st.navigation, including backend model updates, protobuf destination metadata, frontend link behavior for sidebar/top nav, and corresponding unit + e2e coverage. Internal navigation behavior remains intact while external pages are opened in new tabs and explicitly blocked from st.switch_page.

Code Quality

The implementation is clean and consistent across backend/frontend boundaries, with good use of helper abstractions (isExternalPage, getExternalPageUrl, _set_page_destination) and targeted tests.

No blocking code-quality defects were identified.

Minor follow-up (non-blocking):

  • lib/streamlit/navigation/page.py (~234-239): the class-level visibility attribute docstring still says hidden pages remain accessible via URL/st.switch_page, which is no longer true for external pages.

Test Coverage

Coverage is strong and appropriately layered:

  • Python unit tests cover external-page construction/validation/sanitization, st.navigation default/selection behavior, protobuf destination fields, st.switch_page rejection, and st.page_link external-page behavior.
  • Frontend unit tests cover external-link rendering and interaction behavior in SidebarNav, TopNav, TopNavSection, and SidebarNavLink, including anti-regression checks (onPageChange not called for external links).
  • New e2e tests validate sidebar + top-nav external link attributes and internal navigation continuity.

Net: the changed behavior is well covered for local CI contexts.

Backwards Compatibility

Backwards compatibility looks good:

  • AppPage adds new protobuf fields (internal, external) without changing existing field numbers/semantics.
  • Existing frontend/backends can continue relying on prior fields (page_script_hash, url_pathname, etc.).
  • Internal page behavior is preserved; external behavior is additive and guarded.

Security & Risk

Security posture is improved/maintained:

  • External links use target="_blank" with rel="noopener noreferrer" in navigation.
  • External-page detection is constrained to http/https via is_url.
  • st.switch_page now rejects external pages to prevent misuse of internal navigation APIs.

No critical security issues found.

External test recommendation

  • Recommend external_test: Yes
  • Triggered categories: 1. Routing and URL behavior; 7. Cross-origin behavior and external networking
  • Evidence:
    • lib/streamlit/navigation/page.py: introduces external URL parsing/validation and URL-path sanitization logic for st.Page.
    • lib/streamlit/commands/navigation.py: adds destination routing semantics and explicit fallback when an external page is requested directly by URL.
    • frontend/app/src/components/Navigation/SidebarNavLink.tsx: external links open cross-origin destinations in new tabs.
    • frontend/app/src/components/Navigation/TopNav.tsx and TopNavSection.tsx: click handling diverges for external vs internal pages.
  • Suggested external_test focus areas:
    • Embedded/iframe-hosted app: verify external nav links open correctly and do not trigger internal reruns/page-hash changes.
    • Externally hosted app behind base path/proxy: verify URL/path resolution and fallback behavior when navigating to external page pathnames directly.
  • Confidence: Medium
  • Assumptions and gaps: assessment is static (no runtime execution here); current e2e tests validate local behavior but are not marked/executed in external modes.

Accessibility

Accessibility handling is generally good:

  • Navigation remains semantic (<a href> for navigation).
  • External links include non-visual cue text ((opens in new tab)) for assistive tech.
  • aria-current is suppressed for external links to avoid announcing them as the active in-app page.

No blocking a11y regressions found.

Recommendations

  1. Update the stale class docstring text in lib/streamlit/navigation/page.py (~234-239) to reflect that hidden external pages are not directly URL-accessible and are not switchable via st.switch_page.
  2. In e2e_playwright/st_navigation_external_links.py (~38-39), consider replacing third-party URLs with inert/test-stable URLs (or local assets) to align with e2e_playwright/AGENTS.md guidance on avoiding external test dependencies.

Verdict

APPROVED: The feature is implemented coherently across backend/frontend/proto layers with solid coverage and no blocking regressions identified.


This is an automated AI review by gpt-5.3-codex-high. Please verify the feedback and use your judgment.

📋 Review by `opus-4.6-thinking`

Summary

This PR adds support for external web links in st.navigation, allowing users to include external URLs (http/https) alongside internal pages in both sidebar and top navigation modes. Clicking an external link opens the URL in a new browser tab (target="_blank"). The change spans the full stack: protobuf definitions, Python backend (st.Page, st.navigation, st.switch_page, st.page_link), and React frontend (sidebar nav, top nav, styled components). It includes comprehensive unit tests, frontend tests, and E2E tests.

Code Quality

The code is well-structured and follows existing patterns consistently across the stack.

Backend (Python):

  • The _sanitize_url_path helper in lib/streamlit/navigation/page.py (line 31) is correctly prefixed with _ as it's module-private. The regex-based sanitization is clear and well-ordered.
  • The early-return pattern in StreamlitPage.__init__ (line 314) for external URLs cleanly separates the external path from the internal path, avoiding deeply nested conditionals.
  • _set_page_destination in navigation.py (line 81) is a clean helper using the protobuf oneof pattern correctly — SetInParent() for the empty InternalDestination is the right approach to explicitly set the oneof discriminator.
  • The type annotation for self._page is declared as Path | Callable[[], None] | None on the external branch (line 291), while the internal branch (line 343) omits the annotation. This works because mypy recognizes the first annotation as the attribute declaration. However, it would be slightly cleaner to declare it once (e.g., at class-level or at the top of __init__).

Frontend (TypeScript/React):

  • The isExternalPage and getExternalPageUrl utility functions in utils.ts are properly extracted and reused across SidebarNav, TopNav, and TopNavSection.
  • The removal of {...item} spread in TopNavSection.tsx (previously spreading all proto fields as props) is a good improvement — explicit props are safer and more maintainable.
  • The handleClick return type change from boolean to void in TopNavSection.tsx is correct; the return value was unused.
  • The StyledVisuallyHidden component uses the standard CSS visually-hidden pattern with properly scoped eslint-disable for the hardcoded-values rule.

Protobuf:

  • The destination oneof with InternalDestination (empty) and ExternalDestination (url field) is a clean design. Using a oneof clearly communicates that a page is one or the other.

Test Coverage

Test coverage is thorough and well-structured.

Python unit tests:

  • page_test.py: 15 new tests covering URL detection, title validation, url_path sanitization, edge cases (empty/whitespace titles, dangerous URL schemes, nested paths), and the run() no-op behavior. Uses @parameterized.expand appropriately.
  • navigation_test.py: 7 new tests covering all-external-pages error, mixed pages default selection, proto field serialization, direct URL fallback, and duplicate url_path detection.
  • execution_control_test.py: Properly adds is_external = False to existing mocks and adds a dedicated test for the external page rejection.
  • page_link_test.py: Tests st.page_link with external StreamlitPage objects, including label override.
  • Proto compatibility test properly updated with new fields.

Frontend unit tests:

  • SidebarNavLink.test.tsx: Comprehensive tests for external link attributes (target, rel, href), fallback behavior, onClick passthrough, top nav variant, screen reader text, and the aria-current non-application for external pages.
  • SidebarNav.test.tsx: Tests click behavior (no onPageChange for external), mobile sidebar collapse, and hidden page filtering.
  • TopNav.test.tsx: Mirrors SidebarNav tests for top navigation context.
  • TopNavSection.test.tsx: New file testing dropdown behavior with mixed internal/external links.
  • utils.test.ts: Tests for the new isExternalPage and getExternalPageUrl helpers.

E2E tests:

  • 3 tests covering sidebar attributes, sidebar internal navigation, and top nav attributes + navigation. Tests consolidate sidebar and top nav verification into aggregated scenarios per the E2E best practices (fewer browser loads).
  • The E2E app uses external URLs (https://docs.streamlit.io, https://streamlit.io) as metadata strings in st.Page — these are never navigated to by the test assertions (only attribute checks), so this does not violate the "no external URLs" guideline for test assets.

Minor gaps:

  • No snapshot/visual tests for the external links in the navigation (how they render visually). This is acceptable since external links look identical to internal links in the current implementation.
  • No tests for st.page_link with a hidden external StreamlitPage (the docs say hidden external pages "can only be opened via st.page_link").

Backwards Compatibility

Protobuf: The changes are purely additive — new fields 8 and 9 on AppPage using a oneof. Old clients that don't understand these fields will ignore them. Old backends that don't set the destination oneof will leave it unset, and the frontend's isExternalPage() checks for page.external being non-null, so unset pages are correctly treated as internal. The proto file's NOTE about external services is respected — no existing fields were modified.

Python API: st.Page continues to accept the same parameter signature (page: str | Path | Callable). External URLs are a new category of str inputs. Existing code passing file paths or callables is unaffected. The is_external and external_url properties are new additions to StreamlitPage with no impact on existing users.

Frontend: The isExternal and externalUrl props added to SidebarNavLink are optional (? suffix), so existing callers that don't pass them will get undefined, which evaluates to falsy — correctly treating existing links as internal.

No breaking changes identified.

Security & Risk

URL scheme restriction: The is_url() function in url_util.py restricts allowed schemes to ("http", "https") by default. The test test_dangerous_url_schemes_not_treated_as_external explicitly verifies that javascript:, vbscript:, and data: schemes are rejected. This is a solid defense.

rel="noopener noreferrer": Correctly applied to all external links, preventing window.opener attacks.

Direct URL access prevention: External pages cannot be accessed by navigating directly to their URL path (navigation.py line 473). This prevents users from crafting URLs that might cause unexpected behavior.

st.switch_page rejection: Properly blocks programmatic navigation to external pages with a clear error message.

No server-side request risk: The external URL is only passed to the frontend as metadata; the backend never fetches or follows the URL. All navigation happens client-side.

Low regression risk: Changes to existing files are minimal and well-guarded by feature detection (is_external checks).

External test recommendation

Recommendation: No@pytest.mark.external_test coverage is not needed for this branch.

Triggered categories: None.

Key evidence:

  • The E2E tests depend on the specific test app structure (radio button for position switching, specific page configuration).
  • Tests use the app: Page fixture, not app_target.
  • The feature's E2E behavior (rendering correct attributes on external links) depends on the specific st.Page configuration in the test script.
  • Tests cannot run against an arbitrary externally hosted app.

Confidence: High. The tests are inherently local-only due to their dependence on the app script's navigation structure.

Accessibility

Good practices:

  • Screen reader announcement via StyledVisuallyHidden with "(opens in new tab)" text on external links — a standard a11y pattern for links opening in new tabs.
  • aria-current="page" is correctly suppressed for external links (they can never be the "current" page).
  • External links use semantic <a> elements with proper href, target, and rel attributes.
  • rel="noopener noreferrer" on external links.

Potential improvement:

  • There is no visual indicator (such as an external link icon or arrow) to inform sighted users that a link opens in a new tab. The screen reader text addresses assistive technology users, but sighted users don't get an equivalent cue. This is a UX/design consideration rather than a code defect; a follow-up could add a small external-link icon for visual clarity.

Recommendations

  1. Consider a visual external link indicator: Add a small icon (e.g., :material/open_in_new:) next to external links to visually signal that they open in a new tab. This would provide parity with the screen reader "(opens in new tab)" text. This could be a follow-up enhancement.

  2. Consider declaring self._page type at class level or top of __init__: Currently, the type annotation Path | Callable[[], None] | None is only declared in the external branch (page.py line 291), while the internal branch relies on inference. A single declaration before the branching (e.g., after line 263) would make the intent clearer:

    self._page: Path | Callable[[], None] | None = None
  3. Add a test for st.page_link with a hidden external page: The docstring states hidden external pages "can only be opened via st.page_link." A test verifying this works correctly would close the loop on the documented behavior.

  4. Minor E2E consolidation opportunity: The sidebar attributes test (test_sidebar_links_have_expected_attributes) and sidebar navigation test (test_sidebar_internal_navigation_still_works) could be merged into a single scenario test (per E2E AGENTS.md guidance to prefer aggregated scenarios). The tests share the same initial page state and could reduce one browser load.

Verdict

APPROVED: Well-implemented feature with comprehensive test coverage, proper security safeguards, good accessibility practices, and fully backward-compatible changes. The recommendations are minor enhancements that can be addressed in follow-up work.


This is an automated AI review by opus-4.6-thinking.

sfc-gh-bnisco and others added 2 commits March 4, 2026 14:02
Replace falsy check with nullish coalescing (`??`) so that an
external page with an empty-string URL is not incorrectly routed
to the internal pageUrl.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Update the `visibility` parameter docstring in `StreamlitPage` to
reflect that hidden external pages are not URL-accessible or
switchable via `st.switch_page`, unlike hidden internal pages.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@sfc-gh-bnisco
Copy link
Copy Markdown
Collaborator

@t0k0shi Just a heads up that I pushed some changes to this branch intentionally to fix up a few edge cases and add some additional tests.

Comment on lines +37 to +50
// Destination for this page.
// Internal pages target app code. External pages target a URL.
// The navigation payload backend always sets one of these fields.
oneof destination {
InternalDestination internal = 8;
ExternalDestination external = 9;
}

message InternalDestination {}

message ExternalDestination {
// External URL that should be opened for this page.
string url = 1;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: I think this could also be simplified by just having a single optional string url property, if set, its an external destination. However, this is a bit more explicit, but maybe would benefit from moving internal-only props into the InternalDestination, e.g., page_script_hash?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this might be over-engineering. I pushed an update that simplifies it just optional string external_url

@sfc-gh-bnisco sfc-gh-bnisco merged commit e7b75a7 into streamlit:develop Mar 5, 2026
42 checks passed
lukasmasuch pushed a commit that referenced this pull request Mar 10, 2026
Add support for external web links in st.navigation, allowing users to
include external URLs alongside internal pages in both sidebar and top
navigation modes.
This implements the feature requested in #9025.

  Changes

  Backend (lib/streamlit/navigation/page.py)

  - Detect HTTP/HTTPS URLs using existing url_util.is_url()
- Handle external URLs in StreamlitPage.__init__ with title validation
and URL path sanitization
  - Add is_external and external_url properties
  - Skip code execution for external links in run()
- Use explicit None check for url_path to correctly reject empty strings

Backend (lib/streamlit/commands/execution_control.py,
lib/streamlit/elements/widgets/button.py)

- st.switch_page: raise StreamlitAPIException for external pages
(internal navigation only)
- st.page_link: render external StreamlitPage objects as external links

  Protobuf (proto/streamlit/proto/AppPage.proto)

  - Adds optional `external_url ` field

  Frontend

- Update SidebarNavLink to handle external links with target="_blank"
and rel="noopener noreferrer"
  - Update SidebarNav, TopNav, TopNavSection to pass external link info
- Pass props explicitly in TopNavSection instead of spreading proto
fields

  Tests

- Add unit tests for external URL support in page_test.py (URL
detection, title validation, url_path sanitization, edge cases)
  - Add is_external=False to existing switch_page test mocks
  - Add test for st.switch_page rejecting external pages
  - Add tests for st.page_link with external StreamlitPage objects
- Add unit tests for external link click behavior in SidebarNav.test.tsx
and TopNav.test.tsx
  - Add 8 E2E tests for external links in sidebar and top navigation
  - Add proto compatibility test fields

  Example Usage

  import streamlit as st

  def home():
      st.title("Home")

  pages = [
      st.Page(home, title="Home", icon="🏠", default=True),
      st.Page("https://docs.streamlit.io", title="Docs", icon="📚"),
st.Page("https://github.com/streamlit/streamlit", title="GitHub",
icon="🐙"),
  ]
  pg = st.navigation(pages)
  pg.run()

  External links:
  - Require a title parameter (cannot be inferred from URL)
  - Cannot be set as the default page
  - Open in a new tab (target="_blank")
  - st.switch_page raises an error (internal navigation only)
  - st.page_link renders them as external links

  Demo


https://github.com/user-attachments/assets/76a3d917-051c-4e2b-9f1f-fdc33072ddd4

External links in the sidebar open in a new tab. Internal pages navigate
normally.

  Closes #9025

---------

Co-authored-by: Ubuntu <azureuser@dev-keru09.yuc22wcrz1luho2fqzdqlkjrgg.bx.internal.cloudapp.net>
Co-authored-by: t0k0shi <[email protected]>
Co-authored-by: Claude Opus 4.5 <[email protected]>
Co-authored-by: Bob Nisco <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:feature PR contains new feature or enhancement implementation impact:users PR changes affect end users status:product-approved Community PR is approved by product team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow links to external web pages in the new navigation sidebar

6 participants