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)
- 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 |
canActivate |
array | ❌ | [] |
Guards that must allow navigation before the view is shown (see Route Guards) |
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 destroyedonViewSuspend/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, 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()
.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), 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).
- Join the Roku Developers Slack
- Report issues or request features via GitHub Issues
Licensed under the MIT License.
