Skip to content
ChatfuelSDK
Concepts

Routing

The shell owns the address bar and hands each module a view and its params, which is why a module never touches window.location.

A module never touches window.location. The shell owns the address bar, and each module receives its own slice of it through props — which is what lets the same module tree run at a domain root, at a sub-path, or embedded in somebody else's app without a line of it changing.

The router is hand-rolled and about 160 lines. There is no react-router, because the vendored dependency set stays frozen: everything you would have to install to run this app is already in it.

The shape of an address

/<moduleId>[/<view>]?<params>

The first segment is the module id — the same string as the directory name and the manifest id. The second, when there is one, is the module's own view. Everything after ? is that module's deep-link state.

/contacts is the contacts module at its root. /contacts/fields?density=compact is the same module, a different screen, with one parameter it understands.

What a module gets

Five props, and no way around them.

PropWhat it is
viewthe path segment after the module id, '' at the module's root
setViewmove within this module — a view change is a place, so it pushes
paramsthis module's deep-link params, the part after ?
setParamsreplace them, with replaceState, adding no history entry
navigatesomewhere else in the app, as an app-relative path like /livechat?c=42

That split is the whole navigation policy. A view is a destination and belongs in the back stack; a filter, a density or an open row is a refinement of where you already are and does not. Get it backwards and the Back button either does nothing or takes eleven presses to leave a screen.

When the address points at some other module, the one being rendered gets view: '' — it is not the page that was asked for, so it opens at its root rather than at a screen it half-recognises.

Every write comes back through one listener

pushState fires no event of its own, so the shell's navigate dispatches the popstate that everything already listens for. One code path, whether the shell moved, a module linked into another module, the assistant undid a navigation, or somebody pressed Back.

Going nowhere is not a navigation: an identical address is dropped. That is also what stops a module which writes its params on every render from looping.

Modules write ordinary anchors — "Open in Live Chat", "set this up in Automations" — and a module may not touch the router, so the shell catches the click instead. Same-origin, unmodified, not a new tab, not a download, no rel="external": it is turned into a navigation. Everything else is left to the browser.

An app-relative href is resolved against the mount point at that moment, because the mount point is the one thing a module is not allowed to know.

Where the app is mounted is the shell's business

The app can be served from a sub-path, and BASE comes from Vite's base option — the same value that rewrote the asset URLs in index.html, so a build served from anywhere else is broken before routing gets involved.

The rule that follows holds everywhere: inside the app, an address is written app-relative, and exactly one file knows where the app really is.

Addresses minted while this app routed in the fragment — #/livechat?c=1 — are still in invite mail, in password-reset mail and in bookmarks. One function rewrites one of those, once, before the first render.

Supabase's own bare key=value fragment is left exactly where it is, and resolves to the auth callback whatever the path says, so the gate can explain what happened.

The rule every host has to obey

Real paths mean /deals/board is a real address, and a host that answers it with a 404 produces a failure that looks like a routing bug and is not one. Each host needs its own SPA fallback — see picking a host.

What it costs

Address parsing is per module and it fails silently by design: a value the parser does not recognise falls back to the default rather than throwing, because a stale link has to render something. The practical consequence is that a view you forgot to add to the module's list of accepted values looks like a tab that does nothing. Your first change walks through exactly that.

And / is nobody's screen. Until there is a landing page the app opens on the first module in the menu and rewrites the address to say which one — never on a hidden surface.

On this page