Skip to main content
Foldkit
On this pageThe Biparser Approach

Routing & Navigation

Foldkit uses a bidirectional routing system where you define routes once and use them for both parsing URLs and building URLs. No more keeping route matchers and URL builders in sync.

The Biparser Approach

Most routers make you define routes twice: once for matching URLs, and again for generating them. This leads to duplication and bugs when they get out of sync.

Foldkit's routing is based on biparsers — parsers that work in both directions. A single route definition handles:

  • /people/42PersonRoute { personId: 42 } (parsing)
  • PersonRoute { personId: 42 }/people/42 (building)

This symmetry means if you can parse a URL into data, you can always build that data back into the same URL.

Defining Routes

Routes are defined as tagged unions using Effect Schema. Each route variant carries the data extracted from the URL.

import { Schema as S } from 'effect'
import { r } from 'foldkit/route'

const HomeRoute = r('Home')
const PeopleRoute = r('People', { searchText: S.Option(S.String) })
const PersonRoute = r('Person', { personId: S.Number })
const NotFoundRoute = r('NotFound', { path: S.String })

const AppRoute = S.Union(
  HomeRoute,
  PeopleRoute,
  PersonRoute,
  NotFoundRoute,
)

type AppRoute = typeof AppRoute.Type
  • HomeRoute — no parameters
  • PersonRoute — holds a personId: number
  • PeopleRoute — holds an optional searchText: Option<string>
  • NotFoundRoute — holds the unmatched path: string

Building Routers

Routers are built by composing small primitives. Each primitive is a biparser that handles one part of the URL.

import { Schema as S, pipe } from 'effect'
import { Route } from 'foldkit'
import { int, literal, slash } from 'foldkit/route'

// Matches: /
const homeRouter = pipe(Route.root, Route.mapTo(HomeRoute))

// Matches: /people or /people?searchText=alice
const peopleRouter = pipe(
  literal('people'),
  Route.query(
    S.Struct({
      searchText: S.OptionFromUndefinedOr(S.String),
    }),
  ),
  Route.mapTo(PeopleRoute),
)

// Matches: /people/42
const personRouter = pipe(
  literal('people'),
  slash(int('personId')),
  Route.mapTo(PersonRoute),
)

The primitives:

  • literal('people') — matches the exact segment people
  • int('personId') — captures an integer parameter
  • string('name') — captures a string parameter
  • slash(...) — chains path segments together
  • Route.query(Schema) — adds query parameter parsing
  • Route.mapTo(RouteType) — converts parsed data into a typed route

Parsing URLs

Combine routers with Route.oneOf and create a parser with a fallback for unmatched URLs.

import { Route, Runtime } from 'foldkit'
import { evo } from 'foldkit/struct'
import { Url } from 'foldkit/url'

// Combine routers - order matters! More specific routes first.
const routeParser = Route.oneOf(
  personRouter, // /people/:id - try first (more specific)
  peopleRouter, // /people?search=...
  homeRouter, // /
)

// Create a parser with a fallback for unmatched URLs
const urlToAppRoute = Route.parseUrlWithFallback(
  routeParser,
  NotFoundRoute,
)

// In your init function, parse the initial URL:
const init: Runtime.ApplicationInit<Model, Message> = (url: Url) => {
  return [{ route: urlToAppRoute(url) }, []]
}

// In your update function, handle URL changes:
UrlChanged: ({ url }) => [
  evo(model, {
    route: () => urlToAppRoute(url),
  }),
  [],
]

Order matters in oneOf. Put more specific routes first — /people/:id should come before /people so the parameter route gets a chance to match.

Building URLs

Here's where the biparser pays off. The same router that parses URLs can build them:

// Building URLs from route data - same router, opposite direction!

const homeUrl = homeRouter.build({})
console.log(homeUrl)
// '/'

const peopleUrl = peopleRouter.build({ searchText: Option.none() })
console.log(peopleUrl)
// '/people'

const searchUrl = peopleRouter.build({
  searchText: Option.some('alice'),
})
console.log(searchUrl)
// '/people?searchText=alice'

const personUrl = personRouter.build({ personId: 42 })
console.log(personUrl)
// '/people/42'

// Use in your view to create type-safe links:
a([Href(personRouter.build({ personId: person.id }))], [person.name])

TypeScript ensures you provide the correct data. If personRouter expects { personId: number }, you can't accidentally pass a string or forget the parameter.

Query Parameters

Query parameters use Effect Schema for validation. This gives you type-safe parsing, optional parameters, and automatic encoding/decoding.

import { Schema as S, pipe } from 'effect'
import { Route } from 'foldkit'
import { literal } from 'foldkit/route'

// Query parameters use Effect Schema for validation
const searchRouter = pipe(
  literal('search'),
  Route.query(
    S.Struct({
      q: S.OptionFromUndefinedOr(S.String),
      page: S.OptionFromUndefinedOr(S.NumberFromString),
      sort: S.OptionFromUndefinedOr(S.Literal('asc', 'desc')),
    }),
  ),
  Route.mapTo(SearchRoute),
)

// Parsing /search?q=hello&page=2&sort=asc gives you:
// → SearchRoute { q: Some('hello'), page: Some(2), sort: Some('asc') }

// Building
const searchUrl = searchRouter.build({
  q: Option.some('hello'),
  page: Option.some(2),
  sort: Option.none(),
})
console.log(searchUrl)
// '/search?q=hello&page=2'

S.OptionFromUndefinedOr makes parameters optional — missing params become Option.none(). S.NumberFromString automatically parses string query values into numbers.

For a complete routing example, see the Routing example. For a deeper look at query parameters — custom schema transforms, lenient parsing, and bidirectional URL sync — see the Query Sync example.

Keying Route Views

When rendering different routes in the same DOM position, you should key the content by the route tag. This tells Snabbdom (which Foldkit uses for virtual DOM diffing) that different routes are distinct trees that should be fully replaced rather than patched.

import { html } from 'foldkit/html'

const { main, keyed } = html<Message>()

// Key by route tag so Snabbdom knows these are distinct trees
main([], [keyed('div')(model.route._tag, [], [routeContent])])

Without the key, Snabbdom tries to diff the old and new route views as if they were the same tree. This can cause unexpected behavior when routes have different structures.

In React, this happens automatically — different component types in the same position cause a full remount. In Foldkit, you achieve the same behavior by explicitly keying with model.route._tag.

Foldkit provides navigation commands for programmatically changing the URL. These are returned from your update function like any other command.

import { Effect, Schema as S } from 'effect'
import { Navigation } from 'foldkit'
import { m } from 'foldkit/message'

const NoOp = m('NoOp')
const Message = S.Union(NoOp)
type Message = typeof Message.Type

const pushUrl = Navigation.pushUrl('/people/42').pipe(
  Effect.as(NoOp()),
)

const replaceUrl = Navigation.replaceUrl('/people/42').pipe(
  Effect.as(NoOp()),
)

const goBack = Navigation.back().pipe(Effect.as(NoOp()))

const goForward = Navigation.forward().pipe(Effect.as(NoOp()))

const loadUrl = Navigation.load('https://example.com').pipe(
  Effect.as(NoOp()),
)
  • Navigation.pushUrl — adds a new entry to browser history
  • Navigation.replaceUrl — replaces the current history entry (no back button)
  • Navigation.back / Navigation.forward — navigate through browser history
  • Navigation.load — full page load (for external URLs)

When a link is clicked in your application, the browser.onUrlRequest handler receives either an Internal or External request. Handle Internal links with pushUrl and External links with load:

import { Effect, Match as M, Schema as S, pipe } from 'effect'
import { Navigation, Route, Runtime, Url } from 'foldkit'
import { Command } from 'foldkit/command'
import { m } from 'foldkit/message'
import { int, literal, r, slash } from 'foldkit/route'
import { evo } from 'foldkit/struct'

// ROUTE

const HomeRoute = r('Home')
const PersonRoute = r('Person', { personId: S.Number })
const NotFoundRoute = r('NotFound', { path: S.String })
const AppRoute = S.Union(HomeRoute, PersonRoute, NotFoundRoute)
type AppRoute = typeof AppRoute.Type

const homeRouter = pipe(Route.root, Route.mapTo(HomeRoute))
const personRouter = pipe(
  literal('people'),
  slash(int('personId')),
  Route.mapTo(PersonRoute),
)
const routeParser = Route.oneOf(personRouter, homeRouter)
const urlToAppRoute = Route.parseUrlWithFallback(
  routeParser,
  NotFoundRoute,
)

// MODEL

const Model = S.Struct({ route: AppRoute })
type Model = typeof Model.Type

// MESSAGE

// ClickedLink and ChangedUrl are required for routing
const NoOp = m('NoOp')
const ClickedLink = m('ClickedLink', { request: Runtime.UrlRequest })
const ChangedUrl = m('ChangedUrl', { url: Url.Url })
const Message = S.Union(NoOp, ClickedLink, ChangedUrl)
type Message = typeof Message.Type

// UPDATE

const update = (model: Model, message: Message) =>
  M.value(message).pipe(
    M.withReturnType<[Model, ReadonlyArray<Command<Message>>]>(),
    M.tagsExhaustive({
      NoOp: () => [model, []],

      // Handle link clicks - decide whether to navigate or do a full page load
      ClickedLink: ({ request }) =>
        M.value(request).pipe(
          M.tagsExhaustive({
            // Same-origin link - push to history
            Internal: ({
              url,
            }): [Model, ReadonlyArray<Command<Message>>] => [
              model,
              [
                Navigation.pushUrl(Url.toString(url)).pipe(
                  Effect.as(NoOp()),
                ),
              ],
            ],
            // Different-origin link - full page load
            External: ({
              href,
            }): [Model, ReadonlyArray<Command<Message>>] => [
              model,
              [Navigation.load(href).pipe(Effect.as(NoOp()))],
            ],
          }),
        ),

      // URL changed - parse it and update the route
      ChangedUrl: ({ url }) => [
        evo(model, {
          route: () => urlToAppRoute(url),
        }),
        [],
      ],
    }),
  )

After pushUrl or replaceUrl changes the URL, Foldkit automatically calls your browser.onUrlChange handler with the new URL. This is where you parse the URL into a route and update your model.