A lightweight, modern router for Roku SceneGraph apps. sgRouter maps URL-style paths to components, manages view lifecycles, handles parameters, and supports route guards — enabling dynamic and seamless navigation experiences.
- URL-style navigation for Roku apps
- Dynamic routing with parameter support
- Named routes — navigate by intent, not by hardcoded path strings
- Route guards (
canActivate) for protected screens - View lifecycle hooks for fine-grained control
- Stack management (navigation, suspension, resume, and checkpoint-based unwinding)
- Observable router state for debugging or analytics
Requires Roku Promises
Install via ropm:
npx ropm install promises@npm:@rokucommunity/promises
npx ropm install sgRouter@npm:@rokucommunity/sgrouterA route defines how your Roku app transitions between views. Routes are typically registered in your main scene.
Each route object can include:
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
pattern |
string | ✅ | — | URL-like path pattern ("/details/movies/:id") |
component |
string | ✅ | "" |
View component to render (must extend sgRouter_View) |
name |
string | ❌ | — | Stable identifier for named navigation (e.g. "movieDetail") |
allowReuse |
boolean | ❌ | false |
When true, navigating to the same route calls onRouteUpdate instead of creating a new view |
clearStackOnResolve |
boolean | ❌ | false |
Destroys all previous views in the stack when this route activates |
keepAlive |
object | ❌ | { enabled: false } |
When enabled: true, the view is suspended (not destroyed) when navigated away from |
suspendMode |
string | ❌ | "detach" for keepAlive, else "hide" |
How the view is held while suspended — "detach" (removed from the tree and held in a store, re-attached on resume), "hide" (kept in the tree, hidden and moved off-screen), "show" (kept in the tree, rendered in place). See View suspension |
canActivate |
array | ❌ | [] |
Guards that must allow navigation before the view is shown (see Route Guards) |
outgoingRouteConfigOverrides |
object | ❌ | — | Override applied to the outgoing view when navigating to this route (e.g. { suspendMode: "detach" }) — for that navigation only, never mutating the outgoing route's config. Only suspendMode is supported. See outgoingRouteConfigOverrides |
Views extending sgRouter_View can define:
beforeViewOpen→ Called before the view loads (e.g. async setup, API calls)onViewOpen→ Called after previous view is closed/suspendedbeforeViewClose→ Invoked before a view is destroyedbeforeViewSuspend→ Invoked before a view is hidden/suspended (beforeonViewSuspend)onViewSuspend/onViewResume→ Handle stack suspensions/resumptionsonRouteUpdate→ Fired when navigating to the same route with updated params/hashhandleFocus→ Defines focus handling when the view becomes active
<component name="MainScene" extends="Scene">
<script type="text/brightscript" uri="pkg:/source/roku_modules/sgrouter/router.brs" />
<script type="text/brightscript" uri="MainScene.bs" />
<children>
<sgRouter_Outlet id="myOutlet" />
</children>
</component>sub init()
' Initialize the router at your main outlet
sgRouter.initialize({ outlet: m.top.findNode("myOutlet") })
sgRouter.addRoutes([
{ pattern: "/", component: "WelcomeScreen" },
{ pattern: "/shows", component: "CatalogScreen", clearStackOnResolve: true },
{ pattern: "/movies", component: "CatalogScreen", clearStackOnResolve: true },
{ pattern: "/details/series/:id", component: "DetailsScreen" },
{ pattern: "/details/series/:id/cast", component: "CastDetailsScreen" },
{ pattern: "/details/movies/:id", component: "DetailsScreen" },
{ pattern: "/details/movies/:id/cast", component: "CastDetailsScreen" },
{ pattern: "/:screenName", component: "DefaultScreen" }
])
sgRouter.navigateTo("/") ' Go to the welcome view
' set the focus to the router
sgRouter.setFocus({ focus: true })
end sub<component name="WelcomeScreen" extends="sgRouter_View">
<script type="text/brightscript" uri="pkg:/source/roku_modules/promises/promises.brs" />
<script type="text/brightscript" uri="WelcomeScreen.bs" />
<children>
<Label id="label" />
</children>
</component>sub init()
m.label = m.top.findNode("label")
end sub
' Called before the view is shown
function beforeViewOpen(params as dynamic) as dynamic
m.label.text = "Hello!"
return promises.resolve(invalid)
end functionYou can observe routerState for debugging or analytics:
sub init()
sgRouter.getRouter().observeField("routerState", "onRouterStateChanged")
end sub
sub onRouterStateChanged(event as Object)
data = event.getData()
print `Router state changed: ${data.id} ${data.type} ${data.state}`
end subRouter State Structure:
{
"id": "",
"type": "NavigationStart | RoutesRecognized | GuardsCheckStart | GuardsCheckEnd | ActivationStart | ActivationEnd | ResolveStart | ResolveEnd | NavigationEnd | NavigationCancel | NavigationError",
"url": "", // present on most events
"state": { // present on NavigationEnd and related events
"routeConfig": {},
"queryParams": {},
"routeParams": {},
"hash": ""
},
"error": {} // only present on NavigationError
}Route guards let you allow/deny navigation based on custom logic (e.g., authentication, feature flags).
A guard is any node that exposes a canActivate function. The canActivate route config field takes an array of guards — all must pass before the view is shown.
components/Managers/Auth/AuthManager.xml
<?xml version="1.0" encoding="utf-8"?>
<component name="AuthManager" extends="Node">
<interface>
<field id="isLoggedIn" type="boolean" value="false" />
<function name="canActivate" />
</interface>
</component>components/Managers/Auth/AuthManager.bs
import "pkg:/source/router.bs"
' Decide whether navigation should proceed.
' Return true to allow, false or a RedirectCommand to block/redirect.
function canActivate(currentRequest = {} as Object) as Dynamic
if m.top.isLoggedIn then
return true
end if
dialog = createObject("roSGNode", "Dialog")
dialog.title = "You must be logged in"
dialog.optionsDialog = true
dialog.message = "Press * To Dismiss"
m.top.getScene().dialog = dialog
' Redirect unauthenticated users (e.g., to home or login)
return sgRouter.createRedirectCommand("/login")
end functionCreate an instance and expose it globally (so routes can reference it):
components/Scene/MainScene/MainScene.bs (snippet)
' Create AuthManager and attach to globals
m.global.addFields({
"AuthManager": createObject("roSGNode", "AuthManager")
})
' (Optional) observe auth changes
m.global.AuthManager.observeField("isLoggedIn", "onAuthManagerIsLoggedInChanged")Attach one or more guards to any route using the canActivate array:
sgRouter.addRoutes([
{ pattern: "/", component: "WelcomeScreen", clearStackOnResolve: true },
{ pattern: "/login", component: "LoginScreen" },
' Protected content – requires AuthManager.canActivate to allow
{ pattern: "/shows", component: "CatalogScreen", clearStackOnResolve: true, canActivate: [ m.global.AuthManager ] },
{ pattern: "/movies", component: "CatalogScreen", clearStackOnResolve: true, canActivate: [ m.global.AuthManager ] },
{ pattern: "/details/:type/:id", component: "DetailsScreen", canActivate: [ m.global.AuthManager ] },
{ pattern: "/details/:type/:id/cast", component: "CastDetailsScreen", canActivate: [ m.global.AuthManager ] }
])true→ allow navigationfalse→ block navigation (stay on current view)RedirectCommand→ redirect elsewhere without showing the target route- Create via
sgRouter.createRedirectCommand("/somewhere")
- Create via
Your guard receives currentRequest with the full navigation context, useful for deep-links or conditional flows:
function canActivate(currentRequest as Object) as Dynamic
' currentRequest.route.routeConfig.pattern, currentRequest.route.routeParams, currentRequest.route.queryParams, currentRequest.route.hash, etc.
if currentRequest?.queryParams?.requiresPro = true and not m.top.isProUser then
return sgRouter.createRedirectCommand("/upgrade")
end if
return true
end functionYou can implement a reusable feature flag guard for gradual rollouts:
function canActivate(currentRequest as Object) as Dynamic
feature = currentRequest?.routeParams?.feature ' e.g. "/feature/:feature"
if m.global?.features[feature] = true then
return true
end if
return sgRouter.createRedirectCommand("/")
end function- Toggle login in development:
m.global.AuthManager.isLoggedIn = true - Verify redirects by attempting to navigate to a protected route while logged out:
sgRouter.navigateTo("/shows")
- Listen to router state changes to confirm block/redirect behavior:
sgRouter.getRouter().observeField("routerState", "onRouterStateChanged")
The included test project already wires up an
AuthManagerand protects/shows,/movies, and/details/*routes usingcanActivate.
Named routes let you navigate by a stable identifier instead of a hardcoded path string. If a path pattern ever changes, only the route config needs updating — every navigateTo call site remains valid.
sgRouter.addRoutes([
{ pattern: "/", component: "WelcomeScreen", name: "home", clearStackOnResolve: true },
{ pattern: "/movies/:id", component: "DetailsScreen", name: "movieDetail" },
{ pattern: "/settings", component: "SettingsView", name: "settings" },
])name is optional — routes without one continue to work exactly as before.
Pass an associative array with a name key instead of a path string:
' Static route — no params needed
sgRouter.navigateTo({ name: "home" })
' Dynamic route — params are substituted into :segment placeholders
sgRouter.navigateTo({ name: "movieDetail", params: { id: 42 } })
' Resolves to: /movies/42
' Extra params beyond what the pattern requires become query parameters
sgRouter.navigateTo({ name: "movieDetail", params: { id: 42, autoplay: true } })
' Resolves to: /movies/42?autoplay=trueString arguments are unchanged — literal path logic runs with zero overhead:
sgRouter.navigateTo("/movies/42") ' still works exactly as beforeNamed routes remove the need for client code to reconstruct URL strings from backend responses:
' Backend response: { screen: "movieDetail", id: 42 }
response = m.global.ApiManager.getDeepLink()
sgRouter.navigateTo({ name: response.screen, params: { id: response.id } })If the name is not found or a required param is missing, a warning is printed and navigation is cancelled. The history stack is unchanged and no lifecycle hooks are triggered.
sgRouter.navigateTo({ name: "doesNotExist" })
' [WARN] sgRouter: no route found with name "doesNotExist"
sgRouter.navigateTo({ name: "movieDetail" })
' [WARN] sgRouter: missing required param "id" for route "movieDetail" (/movies/:id)Extra params beyond what the pattern requires are silently appended as query parameters — no warning is logged.
Duplicate names at registration time log a warning and the first registration wins:
' [WARN] sgRouter: duplicate route name "home" — first registration wins (existing: /, ignored: /home)Every view lifecycle receives a route snapshot so your screen logic can react to the URL that triggered navigation.
beforeViewOpen, onViewOpen, beforeViewClose, beforeViewSuspend, onViewSuspend, and onViewResume all receive a params object constructed by the router just before the lifecycle is called, which includes:
params.route.routeConfig ' the matched route definition
params.route.routeParams ' extracted from pattern placeholders (e.g. :id, :type)
params.route.queryParams ' parsed from ?key=value pairs
params.route.hash ' parsed from #hash
params.route.navigationState ' how this navigation was triggered:
.fromPushState ' true on normal forward navigation
.fromPopState ' true when arriving via goBack() or popToCheckpoint()
.fromKeepAlive ' true when a keepAlive view is resumed
.fromRedirect ' true when arrived via a canActivate guard redirect
The snapshot is sourced from the URL you navigated to (e.g. "/details/movies/42?page=2&sort=trending#grid=poster"). The router builds this object and passes it into beforeViewOpen(params), onViewOpen(params), beforeViewClose(params), beforeViewSuspend(params), onViewSuspend(params), and onViewResume(params).
onRouteUpdate is different — it receives an object with both the old and new route (params.oldRoute and params.newRoute), so you can diff the two and respond to exactly what changed.
' CatalogScreen.bs (excerpt)
function beforeViewOpen(params as object) as dynamic
' Read route params (e.g., /:type and /:id)
contentType = params.route.routeParams?.type ' "shows" or "movies"
itemId = params.route.routeParams?.id ' e.g., "42"
' Read query params (?page=2&sort=trending)
pageIndex = val(params.route.queryParams?.page) ' 2
sortKey = params.route.queryParams?.sort ' "trending"
' Optional: hash fragment (#grid=poster)
gridMode = params.route.hash
' Kick off data loading based on URL snapshot
' ... start tasks or fetches here ...
' Return a promise to delay opening until ready,
' or return true to open immediately and manage loading UI yourself.
return promises.resolve(invalid)
end function
' If you navigate to the **same route pattern** with different params or hash,
' `onRouteUpdate(params)` will fire (when `allowReuse` is enabled),
' allowing you to update the view without rebuilding it.
' CatalogScreen.bs (excerpt)
function onRouteUpdate(params as object) as dynamic
oldRoute = params.oldRoute
newRoute = params.newRoute
return promises.resolve(invalid)
end functionThe route snapshot is assembled by the router by parsing:
- the pattern match result →
routeParams - the query string →
queryParams - the hash →
hash
That structured object is then provided to the view lifecycles mentioned above. This keeps your screens URL-driven and easy to test (you can navigate with different URLs and assert behavior based on params).
When you navigate away from a view, the router suspends the outgoing view rather than tearing it down immediately. Two things control this: the suspendMode route option (how the view is hidden) and the beforeViewSuspend lifecycle hook (a chance to run code before it's hidden).
This applies to every outgoing view — both keepAlive views (which are retained for later resumption) and ordinary views (which stay around in the stack for goBack). Only views that are actually destroyed (non-keepAlive views removed by clearStackOnResolve, popToCheckpoint, etc.) go through beforeViewClose instead.
suspendMode decides how a view is held once it has been suspended:
| Mode | Behaviour |
|---|---|
"detach" |
The view is removed from the SceneGraph tree entirely and held in an internal store, then re-attached when it is resumed. Frees its render/texture cost completely while suspended. This is the default for keepAlive routes. |
"hide" |
The view is kept in the tree but hidden (visible = false) and moved off-screen (translation = [10000, 10000]). This is the default for ordinary (non-keepAlive) routes. |
"show" |
The view is left rendered in place (visible = true, position unchanged). Use this when the outgoing view should remain on screen underneath the incoming one — e.g. a transparent overlay, or a cross-fade you drive yourself. |
The default is keepAlive-aware: a
keepAliveroute defaults to"detach"(it is retained across stack clears, so it pays to free its render cost while suspended), while an ordinary route defaults to"hide". Visibility — and tree membership, for"detach"— is restored on resume.
Unknown
suspendModevalues fall back to the default for that route (a warning is printed ataddRoutestime). Values are case-sensitive.
sgRouter.addRoutes([
{ pattern: "/shows", component: "CatalogScreen", suspendMode: "show" },
{ pattern: "/movies", component: "CatalogScreen", keepAlive: { enabled: true } } ' defaults to "detach"
])Sometimes the incoming route knows best how the outgoing view should be suspended. For example, a full-screen player might want the screen underneath it to fully detach, even though that screen normally hides. outgoingRouteConfigOverrides lets the incoming route override the outgoing/suspending view's suspendMode for that one navigation only — it never mutates the outgoing route's registered config.
Only
suspendModemay be overridden today; any other key is ignored (with a warning), as is an invalidsuspendModevalue. This keeps the surface safe — you can't, for example, flipkeepAliveon a view that is already mounted and suspending.
There are three ways to supply it, listed lowest → highest precedence (each merges over the previous):
- On the incoming route config — applies every time you navigate to that route:
sgRouter.addRoutes([
{ pattern: "/home", component: "HomeScreen" }, ' normally suspends per its own suspendMode
{ pattern: "/player", component: "PlayerScreen", outgoingRouteConfigOverrides: { suspendMode: "detach" } }
])- As a
navigateTooption — applies to a single call:
sgRouter.navigateTo("/player", { outgoingRouteConfigOverrides: { suspendMode: "detach" } })- Resolved from the incoming view's
beforeViewOpen— when the decision is dynamic (depends on data the incoming view loads):
function beforeViewOpen(params as Object) as Dynamic
' ...decide based on params/state...
return Promises.resolve({ outgoingRouteConfigOverrides: { suspendMode: "detach" } })
end functionThe override is applied to the outgoing view's per-navigation route node, used while it suspends, then reverted — so it does not leak onto the view if it is later resumed (e.g. via
goBack). It applies on forward navigation (wherebeforeViewOpenruns);goBackdoes not trigger it.
beforeViewSuspend(params) fires on the outgoing view while it is still visible, before it is hidden. Like beforeViewOpen, it can return a promise — the router waits for that promise to resolve before hiding the view and showing the next screen.
The lifecycle ordering is guaranteed to be:
beforeViewOpen(next) ' the incoming view prepares (data load, etc.)
→ beforeViewSuspend(prev) ' awaited — outgoing view is still visible here
→ (prev is hidden per suspendMode)
→ onViewSuspend(prev)
→ onViewOpen(next) ' incoming view is now shown
Because it runs after the next view's beforeViewOpen and blocks until your promise resolves, the hook only proceeds once the next screen is ready. It receives the same route snapshot params as the other lifecycle hooks.
What you do in the hook is up to you — flush analytics, persist scroll position, release resources, and so on. As one example, because the outgoing view is still visible and the router awaits your promise, you could play a fade-out and resolve only when it finishes:
' --- CatalogScreen.bs ---
function beforeViewSuspend(params = {} as object) as dynamic
' Resolve any prior pending promise first so it can never dangle if a new
' navigation interrupts an in-flight fade-out.
resolveSuspendPromise()
' Create a deferred and return it; resolve it when the fade-out completes.
m.suspendPromise = promises.create()
m.fadeOutInterpolator.keyValue = [m.container.opacity, 0]
m.fadeAnimation.observeField("state", "onFadeAnimationStateChanged")
m.fadeAnimation.control = "start"
return m.suspendPromise
end function
sub onFadeAnimationStateChanged(event as object)
if event.getData() = "stopped" then resolveSuspendPromise()
end sub
' Resolves the pending promise exactly once and stops observing — idempotent, so
' it's safe to call from the observer, on re-entry, or on teardown.
sub resolveSuspendPromise()
m.fadeAnimation.unobserveField("state")
if m.suspendPromise <> invalid then
promise = m.suspendPromise
m.suspendPromise = invalid
' Resolve the deferred — pass the promise as the SECOND argument
promises.resolve(true, promise)
end if
end sub
⚠️ If you return a promise, make sure it always resolves. Because the router awaitsbeforeViewSuspend, a promise that never resolves will stall navigation. Routing every resolution through a single idempotent helper (as above) guarantees a pending promise is always settled — even if a fade is interrupted by a re-entrant navigation.
Checkpoints let you mark a stack entry as a named "save point" and later unwind back to it in one call. This is useful for multi-step flows (e.g. checkout, onboarding, sign-in) where you want to jump back to a known entry point without calling goBack repeatedly.
Call setCheckpoint at any time to stamp the currently active stack entry:
' Mark the current view with a named identifier
sgRouter.setCheckpoint("checkout-start")identifieris a string. If omitted (orInvalid), the current route's path is used as the identifier.- A single stack entry can hold multiple identifiers — calling
setCheckpointagain with a different name on the same view just adds another identifier. - Returns
falseif the stack is empty.
' Omit the identifier — uses the active route path automatically
sgRouter.setCheckpoint()Call popToCheckpoint to close every view above the most recent matching checkpoint and restore that view as active:
sgRouter.popToCheckpoint("checkout-start")- Searches backwards from the entry below the current view.
- If
identifieris omitted (orInvalid), finds the most recent entry that has any checkpoint. - Returns a promise that resolves when the target view is active.
' Pop to the most recent checkpoint regardless of identifier
sgRouter.popToCheckpoint()' --- CatalogScreen.bs ---
function onViewOpen(params as object) as dynamic
' Mark this view as the start of the checkout flow
sgRouter.setCheckpoint("shop")
return promises.resolve(invalid)
end function
' --- CheckoutConfirmScreen.bs ---
function onCancelPressed() as void
' Jump all the way back to the catalog in one call
promises.chain(sgRouter.popToCheckpoint("shop"), m)
.catch(function(error, m)
print "popToCheckpoint failed: " + error.message
end function)
.toPromise()
end function- Views above the target — removed from the active navigation stack. Non-
keepAliveviews are closed and destroyed;keepAliveviews that were already suspended remain suspended (the checkpoint operation does not affect their suspended state). - The target view — if it was suspended it is restored to
viewTarget(re-attached, if it was detached);onViewResumefires as normal. - History stack — truncated to
[0..targetIndex]for navigation purposes. AgoBackimmediately afterpopToCheckpointsees only the entries up to and including the target, even though suspendedkeepAliveviews above the target are still retained.
' Stack: /home [checkpoint="shop"] → /details/42 → /cart → /checkout
sgRouter.popToCheckpoint("shop")
' Stack after: /home
' /details/42, /cart, and /checkout are removed from navigation; non-keepAlive views are destroyed, while suspended keepAlive views remain suspended; /home is the active viewpopToCheckpoint rejects (returns a rejected promise) in the following situations:
| Situation | Rejection message |
|---|---|
| No matching checkpoint found in the history stack | "popToCheckpoint: no matching checkpoint found in history stack" |
| Another navigation is already in progress | "Navigation already in progress" |
promises.chain(sgRouter.popToCheckpoint("checkout-start"), m)
.then(function(_, m)
' successfully popped to checkpoint
end function)
.catch(function(error, m)
print "popToCheckpoint failed: " + error.message
end function)
.toPromise()- Join the Roku Developers Slack
- Report issues or request features via GitHub Issues
Dependencies flagged by npm audit that we have reviewed and chosen not to upgrade are tracked in audit-ci.jsonc. Each entry includes the advisory ID, the date it was added, and the reason it does not apply to this project.
Licensed under the MIT License.
