Requests, Routes and Matchers

At its core, Navi is just a tool for mapping Requests to Routes; to accomplish this, it uses a pattern called Matchers.

Requests and History

Imagine that you’ve just clicked a link — maybe the one that got you to this page. Your browser needs to ask a server somewhere for the content for that page, so it sends the server a Request. Requests can contain all sorts of information, but at the core of a Request is always a URL, like this one.

https://frontarm.com/navi/core-concepts/

In modern browsers, the History API lets applications change the current URL without reloading the page or sending a new Request. Navi uses this API to keep track of the latest URL, and for programmatic navigation. In fact, for simple apps, you wouldn’t even really need Navi for this — you could just use a simple wrapper around the History API, like the history package used by react-router:

// Update the URL, adding a new entry to the browser history
history.push('/what-are-we-going-to-do-today-brain')

// Update the URL by overwriting the last history entry
history.replace('/the-same-thing-we-do-everyday-pinky')

Of course, for larger apps it’s not enough to just know the URL. You also need to know what data and code are associated with each URL. And in a Navi app, you can find this information in Route objects.

Routes

Imagine that you’ve just clicked a link. Before the browser can display the new page, it’ll need to fetch the information that is required to render that page.

What kind of information is necessary to render the link that you clicked?

  • The document <title>
  • Any metadata in the document <head>
  • A number of React components and elements, including possibly a site-wide layout
  • Any data that will be passed to those React components

In a Navi app, each Request maps to a Route object that contains everything needed to render that Request. The shape of your Route objects is configurable, but usually it’ll look something like this:

let route = {
  url: {
    pathname: '/navi/core-concepts/',
    // ...
  }  
  segments: [/* ... */],
  title: "Core Concepts",
  heads: [
    <meta name="description" content="amazeballs" />,
  ],
  views: [
    <NaviLayout />,
    <NaviMDXLayout MDXComponent={/* ... */} />,
  ],
  data: {
    language: 'en',
  },
}

Once you have the Route object that corresponds to a given Request, rendering it is simple — and the React components in the react-navi package make it even simpler.

But let’s say that you have a Request, and you want a Route. Getting that Route can be trickier than you may first think:

  • The views and data in the Route object may come from multiple different sources.
  • You’ll want to fetch any dependencies in parallel if at all possible.
  • The <AppLayout /> might be shared over the entire sites routes, while the <PageLayout /> probably isn’t.
  • The data might depend on URL parameters or the URL query.

Navi’s key innovation is that it lets you avoid all this and effortlessly declare mappings between URLs and Route objects, using a mechanism called Matchers.

Matchers

Imagine that you’ve just clicked a link, and your browser has navigated to a new URL:

https://frontarm.com/navi/core-concepts/

Looking at this URL, you can infer a few things about the result:

  • Because the URL is under /navi, it’ll use a <NaviLayout> element to render a sidebar on the left.
  • The content for /navi/core-concepts is written with Markdown, so there’ll be a <NaviMDXLayout> element.
  • There’s no language specified in the query string, so it’s going to default to English.

Navi lets you specify each of these rules as individual Matchers — objects that map Requests to individual Chunks of data within your Route. For example, here are some chunks of information about the above URL:

{
  type: 'view',
  url: '/navi',
  view: <NaviLayout />,
}

{
  type: 'view',
  url: '/navi/core-concepts',
  view: <NaviMDXLayout MDXComponent={/* ... */} />,
}

{
  type: 'data',
  url: '/navi/core-concepts',
  data: { language: 'en' },
}

Your Route is then created by reducing all of the matched chunks into a single Route object.

let route = {
  url: {
    pathname: '/navi/core-concepts/',
    // ...
  }  
  chunks: [
    {
      type: 'view',
      url: '/navi',
      view: <NaviLayout />
    },
    {
      type: 'view',
      url: '/navi/core-concepts',
      view: <NaviMDXLayout MDXComponent={/* ... */} />
    },
    {
      type: 'data',
      url: '/navi/core-concepts',
      data: { language: 'en' }
    },
  ],
  views: [
    <NaviLayout />,
    <NaviMDXLayout MDXComponent={/* ... */} />,
  ],
  data: {
    language: 'en',
  },
}

Ok, so matchers map requests to individual chunks of data — which are then reduced into routes. But how do you write matchers? Actually, you probably won’t. Because while it’s entirely possible to write custom matchers (they’re just generator functions), Navi already includes all of the matcher-creating functions that you’ll ever realistically need.

For example, you could use mount() and withView() to map URL paths to views:

mount({
  '/navi': withView(<NaviLayout />)
})

Or you could nest withData() within withView() to match a URL to both its view and its data:

mount({
  '/navi':
    withView(
      <NaviLayout />,
      mount({
        '/core-concepts':
          withView(
            async request => {
              let language = request.params.language || 'en'
              let { default: MDXComponent } =
                await import(`./core-concepts.${language}.mdx`)

              return <NaviMDXLayout MDXComponent={MDXComponent} />
            },
            withData(request => ({
              language: request.params.language || 'en',
            }))
          )
      })
    )
})

You could also use compose() to improve readability by composing matchers instead of nesting them, and you could use dynamic import() with lazy() to split matchers over multiple chunks — improving scalability:

mount({
  '/navi': compose(
    withView(<NaviLayout />),
    mount({
      '/core-concepts': lazy(() => import('./coreConcepts.js'))
    })
  )
})

In fact, you could simplify things even further with route() — which lets you specify view, data, title and any head tags in one function call.

mount({
  '/core-concepts': route(async request => {
    let language = request.params.language || 'en'
    let { default: MDXComponent } =
      await import(`./core-concepts.${language}.mdx`)
    
    return {
      title: 'Core Concepts – Navi',
      head: <meta name="description" content="amazeballs" />,
      data: { language },
      view: <NaviMDXLayout MDXComponent={MDXComponent} />
    }
  })
})

Navi puts a bunch of other matchers at your disposal — for details, see the Matchers Reference. But with this said, in most situations you’ll need no more than mount(), route() and withView().

One curious thing that you may have noticed about the Route objects created by these matchers is that Route can have multiple view elements. This is how Navi handles nested layouts — which we’ll discuss on the next guide. See you there!