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. Include Funicular::RouteHelpers to use the generated path helpers:

class PostListComponent < Funicular::Component
  include Funicular::RouteHelpers

  def render
    ul do
      state.posts.each do |post|
        li do
          link_to post_path(post), navigate: true do
            span { post.title }
          end
        end
      end
    end
  end
end

post_path(post) accepts anything responding to #id, so post_path(post) and 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. Include Funicular::RouteHelpers where you use them:

include Funicular::RouteHelpers

user_path(123)        # => "/users/123"
edit_post_path(post)  # => "/posts/456/edit"  (post responds to #id)
settings_path         # => "/settings"

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 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

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
  link_to dashboard_path, navigate: true,
          class: s.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

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.

Tags: