Funicular ships a client-side router with a Rails-style DSL: named routes, URL parameters, constraints, and path helpers like user_path(user). Navigation happens through the History API, so the browser back/forward buttons just work and there is no full-page reload. This page builds a small routed app, then documents the routing API.
You define routes in app/funicular/initializer.rb, which is compiled and runs in the browser (see Setup).
Tutorial: routing a small app
Define routes when you start the app. Each route maps a path to a component; as: names the route so a path helper is generated:
# app/funicular/initializer.rb
Funicular.start(container: 'app') do |router|
router.get('/posts', to: PostListComponent, as: 'posts')
router.get('/posts/:id', to: PostComponent, as: 'post', constraints: { id: /\d+/ })
router.set_default('/posts') # where "/" lands
end
A dynamic segment (:id) is delivered to the target component as a prop:
class PostComponent < Funicular::Component
def component_mounted
Post.find(props[:id]) { |post, _err| patch(post: post) }
end
def render
div { h1 { state[:post] ? state[:post].title : "Loading #{props[:id]}..." } }
end
end
Link between routes with link_to ..., navigate: true. Generated path helpers are available through routes:
class PostListComponent < Funicular::Component
def render
ul do
state[:posts].each do |post|
li do
link_to routes.post_path(post), navigate: true do
span { post.title }
end
end
end
end
end
end
routes.post_path(post) accepts anything responding to #id, so routes.post_path(post) and routes.post_path(post.id) are equivalent.
Reference
Defining routes
router.get('/about', to: AboutComponent, as: 'about')
router.get('/users/:id', to: UserComponent, as: 'user')
router.post('/posts', to: CreatePostComponent, as: 'create_post')
router.set_default('/login') # redirect target for "/"
get/post/put/patch/delete mirror Rails for semantic clarity; the verb labels the route on the client. Server requests are made through link_to actions or the data layer, not by the router itself.
Constraints
Restrict a parameter with a Regexp; non-matching paths fall through to the next route:
router.get('/users/:id', to: UserComponent, as: 'user', constraints: { id: /\d+/ })
router.get('/posts/:year/:month', to: ArchiveComponent, as: 'archive',
constraints: { year: /\d{4}/, month: /\d{1,2}/ })
Constraints use Regexp#match?, backed by JavaScript’s RegExp engine in PicoRuby.wasm — write patterns in JavaScript regex syntax.
Path helpers
as: generates <name>_path on the current runtime’s route helper object:
routes.user_path(123) # => "/users/123"
routes.edit_post_path(post) # => "/posts/456/edit" (post responds to #id)
routes.settings_path # => "/settings"
Layouts
Wrap routes in router.layout(Component) { ... } to share a frame between pages. A layout is a normal component. It renders the current page where it calls outlet. Layout blocks nest, and a route outside every block has no layout:
# app/funicular/initializer.rb
Funicular.start(container: 'app') do |router|
router.layout(AppLayout) do
router.get('/posts', to: PostListComponent, as: 'posts')
router.get('/posts/:id', to: PostComponent, as: 'post')
router.layout(AdminLayout) do
router.get('/admin/users', to: AdminUsersComponent, as: 'admin_users')
end
end
router.get('/login', to: LoginComponent, as: 'login') # no layout
router.set_default('/posts')
end
class AppLayout < Funicular::Component
def render
div do
header do
link_to routes.posts_path, navigate: true do
span { "Posts" }
end
end
main { outlet }
end
end
end
outlet renders the next layout in the chain, or the matched page when the chain ends. The page receives its URL parameters as props, exactly as it does without a layout. /admin/users renders AppLayout, then AdminLayout at its outlet, then AdminUsersComponent at the inner outlet.
How the router treats the mounted layouts on navigation:
| Navigation | Effect |
|---|---|
| Between routes under the same outermost layout | The router re-renders the mounted layout in place. The layout keeps its state, and the differ patches only the parts that changed. |
| To a route under a different outermost layout | The router unmounts the whole chain and mounts the new one. |
| To a route outside every layout block | The router unmounts the chain and mounts the page alone. |
Notes:
- Layouts need Funicular 0.5.2 or later.
outletreturnsnilwhen the component is not in the current layout chain, so a layout renders nothing at that spot elsewhere.- Navigation guards (see below) and the
beforeunloadlistener consult the page, not the layout. - A page under a layout can redirect from
component_mountedin the usual way. The router remounts the chain when the redirect arrives during a layout re-render. - Server-side rendering renders the page without its layouts, and the client does not hydrate into a layout. See SSR & Hydration.
Programmatic navigation
Funicular.router.navigate('/dashboard')
Funicular.router.current_path # => "/users/123"
Use this after an async action, e.g. navigate once a login resolves.
link_to: navigation vs. action
link_to produces two different things depending on intent:
| Intent | Call | Renders | Effect |
|---|---|---|---|
| Navigation | link_to path, navigate: true |
<a href> |
SPA transition via History API |
| Server action | link_to path, method: :delete |
<div> |
HTTP request via Fetch API |
Navigation links are real anchors, so right-click / open-in-new-tab / copy-link work. Action links are intentionally not anchors — they perform an HTTP request (:get/:post/:put/:patch/:delete) and are the semantically correct element for a server operation. CSRF tokens are attached automatically to non-GET requests (ensure csrf_meta_tags is in your layout).
Customize how an action’s response is handled by overriding handle_link_response:
def handle_link_response(response, path, method)
if response.error?
patch(error: response.error_message)
elsif method == :delete
patch(messages: state[:messages].reject { |m| m.id == response.data[:id] })
end
end
Active-link styling
Compare against the current path to mark the active link (see Styling & Advanced for the styles DSL):
styles do
nav_link base: "px-4 py-2 rounded", active: "bg-blue-100 font-semibold"
end
def render
dashboard_path = routes.dashboard_path
link_to dashboard_path, navigate: true,
class: styles.nav_link(Funicular.router.current_path == dashboard_path) do
span { "Dashboard" }
end
end
Route guards
Guard a route by checking a condition in component_mounted and redirecting:
def component_mounted
unless Session.authenticated?
Funicular.router.navigate('/login')
return
end
load_dashboard
end
Navigation guard (unsaved changes)
Override navigation_guard in a component to ask for confirmation before the user leaves it. Return a message to prompt with, or nil to allow leaving freely:
class PostEditComponent < Funicular::Component
def navigation_guard
@dirty ? "Unsaved changes will be lost. Leave this page?" : nil
end
def handle_input(_event)
@dirty = true # an ivar on purpose: typing should not trigger a re-render
end
def render
div do
textarea(ref: :body, oninput: :handle_input)
end
end
end
The router consults the guard on every way out of the component:
| Leave path | Mechanism |
|---|---|
link_to navigate: true / router.navigate |
window.confirm before the History API is touched |
| Browser back / forward | window.confirm; when the user stays, the history entry is restored |
| Reload / tab close / external link | The browser’s native dialog, via a synchronous beforeunload listener |
Notes:
- The guard must not suspend (no
HTTPcalls, noawait): thebeforeunloadpath runs it on the synchronous JS event dispatch stack. Reading component state is the intended use. - Server-side rendering never blocks; the guard is skipped on the server.
- A page inside a
router.layoutblock is guarded the same way. The router asks the page, not the layout. - Tests (or apps wanting a custom modal instead of
window.confirm) can replace the dialog withFunicular.confirm_handler = ->(message) { ... }returningtrueto leave andfalseto stay. Set it back tonilto restorewindow.confirm. - Remember to clear your dirty flag after a successful save so the guard stands down.
Mirroring Rails routes
Because the helper names match, it is convenient to mirror your Rails routes on the client so the same *_path helpers read the same on both sides:
router.get('/posts/:post_id/comments/:comment_id', to: CommentComponent, as: 'comment')
In the demo
funicular-demo defines its routes in app/funicular/initializer.rb (including a numeric constraints on the chat channel id), and its components navigate with link_to — for example the channel list in chat_components/channel_list_component.rb. The chat and settings routes sit inside a router.layout block, and app_layout_component.rb renders the shared top bar with the page at outlet.