Authenticated Routes

Add authenticated routes to your statically rendered site, complete with redirects to and from the login screen.

When building apps with authentication, you’ll often find that URLs need to behave differently depending on the details of the current user.

For example, say that you have a page that fetches a quote based on an id passed in via URL parameters.

mount({
  '/quote/:id': route(async request => {
    let quote = await fetchQuote(request.params.id)

    return {
      title: `Quote #{request.params.id}`,
      view: quote,
    }
  })
})

Assuming that all of your quotes are meant to be public, this will work great! But imagine for a moment that the above fetchQuote() function needs an auth token. If the current user isn’t logged in, then you’ll be unable to call fetchQuote() — you’ll need to display a login screen instead. And even if the user is logged in, you’ll still need some way of knowing which auth token to pass to fetchQuote().

Authentication State

When you pass a function to route() or a map(), then that function will receive two arguments:

  1. A Request object
  2. A configurable Routing Context

By storing the current authentication state on Navi’s routing context, it becomes possibles to refer to that authentication state within your routes (and within your other matchers).

For example, you could create a route that checks for a context.authToken object, and if it doesn’t exist, just renders a link to the login screen:

index.js
routes.js
resourceRoutes.js
helpers.js
styles.css
import { map, mount, route } from 'navi'
import React from 'react'
import { LoginLink, fetchResource } from './helpers'

export default mount({
  '/:id': map(async (request, context) => {
    // Render a link to the login screen if the user isn't currently 
    // logged in.
    if (!context.currentUser) {
      return route({
        title: 'Please Log In',
        view: <LoginLink redirectTo={request.mountpath} />,
      })
    }

    // Pass in the currentUser object, which could contain a JSON
    // web token that is used for authentication.
    let resource = await fetchResource(
      request.params.id,
      context.currentUser
    )

    // Return a page with the resulting data.
    return route({
      title: resource.title,
      view: resource.content,
    })
  })
})
Build In Progress

Setting authentication state

In order to access the currentUser object through context.currentUser, you’ll first need to add it to your routing context. The simplest way to do this is to pass a context prop to your <Router /> element.

<Router
  routes={routes}
  context={{
    currentUser,
  }}
/>

After adding some component state to manage the value of currentUser, here’s what this will look like in practice:

index.js
routes.js
resourceRoutes.js
helpers.js
styles.css
import React, { Suspense, useEffect, useState } from 'react'
import ReactDOM from 'react-dom'
import { Router, View } from 'react-navi'
import routes from './routes'
import { authService } from './helpers'

function App() {
  // Use state to store the current user
  let [currentUser, setCurrentUser] =
    useState(() => authService.getCurrentUser())

  // Subscribe that state to the value emitted by the auth service
  useEffect(() => authService.subscribe(setCurrentUser), [])

  return (
    // Pass currentUser to router, so it knows whether to redirect
    // the current user to login page for authenticated routes
    <Router routes={routes} context={{ authService, currentUser }}>
      <Suspense fallback={null}>
        <View />
      </Suspense>
    </Router>
  )
}

ReactDOM.render(<App />, document.getElementById('root'))
Build In Progress

Context Caveat

When the context prop changes as judged by a shallow equality check, your entire Route object needs to be recomputed, with all matcher functions being run from scratch.

I go into more details in the Routing Context guide.

Conditional redirects

Creating and styling a login link for each of your pages can be burdensome. In many cases, a simpler solution is to redirect unauthenticated users directly to a login screen — while still displaying the page’s original content to authenticated users. You can implement this by passing a context-consuming function to map() — which lets you switch routes based on the current request or context. Here’s an example:

index.js
routes.js
resourceRoutes.js
helpers.js
styles.css
import { map, mount, redirect, route } from 'navi'
import { fetchResource } from './helpers'

export default mount({
  '/:id': map(async (request, context) => {
    if (!context.currentUser) {
      return redirect(
        "/login/?redirectTo="+
        encodeURIComponent(request.mountpath+request.search)
      )
    }

    // Pass in the currentUser object, which could contain a JSON
    // web token that is used for authentication.
    let resource = await fetchResource(
      request.params.id,
      context.currentUser
    )

    // Return a page with the resulting data.
    return route({
      title: resource.title,
      view: resource.content,
    })
  })
})
Build In Progress

If you have many authenticated pages, you can create a helper that simplifies this even further, like so:

export function withAuthentication(matcher) {
  return map((request, context) =>
    context.currentUser
      ? matcher
      : redirect(
          '/login?redirectTo='+
          encodeURIComponent(request.mountpath+request.search)
        )
  )
}

mount({
  '/account-details':
    withAuthentication(
      route({
        title: 'Account Details',
        getView: (request, context) =>
          <AccountInfo currentUser={context.currentUser} />
      })
    )
})

When you have many authenticated pages at the same level, another approach is to use a wildcard pattern and a non-exact redirect:

mount({
  '/login': lazy(() => import('./login')),

  '*': map(({ context }) =>
    !context.currentUser
      ? redirect(
          '/login?redirectTo='+
          encodeURIComponent(request.mountpath+request.search),
          { exact: false }
        )
      : mount({
          '/account-details': import(() => './account-details'),
          '/billing-history': import(() => './billing-history'),
        })
  )
})

Bypassing the login screen

It often doesn’t make sense for an authenticated user to be viewing a login screen. Luckily, conditional redirects make it possible to automatically redirect the user to wherever they were planning on going:

index.js
routes.js
resourceRoutes.js
helpers.js
styles.css
import { compose, lazy, map, mount, redirect, route, withView } from 'navi'
import React from 'react'
import { View } from 'react-navi'
import { Layout } from './helpers'

export default compose(
  withView((request, context) =>
    <Layout
      currentUser={context.currentUser}
      onLogout={() => context.authService.logout()}
    >
      <View />
    </Layout>
  ),
  mount({
    '/login': map(async (request, context) =>
      context.currentUser ? redirect(
        // Redirect to the value of the URL's `redirectTo` parameter. If no
        // redirectTo is specified, default to `/resource/favorite-foods/`.
        request.params.redirectTo
          ? decodeURIComponent(request.params.redirectTo)
          : '/resource/favorite-foods/'
      ) : route({
        title: 'Login',
        getView: async (req, context) => {
          const { Login } = await import('./helpers')
          return <Login authService={context.authService} />
        },
      })
    ),
    
    '/resource': lazy(() => import('./resourceRoutes')),
    
    '/': redirect('/login'),
  })
)
Build In Progress

Authentication and Static Rendering

When serving a statically rendered site, each page’s HTML will be generated ahead of time. This has big benefits for performance and SEO, but it presents a problem when your app has authenticated routes: if your HTML is generated ahead of time, what HTML should you generate for authenticated routes? What about for routes whose content changes with the environment?

When building your app’s HTML, Navi’s rounting context defaults to an empty object {}. This means that using the redirect-to-login pattern discussed above, Navi will render authenticated routes as redirects to the login screen.

The default behavior of navi-scripts is to render each of your site’s redirects as a HTML file with a <meta http-equiv="redirect"> tag. This means that out of the box, your redirect-to-login routes will work, even with static rendering! Here’s what happens:

  • When an unauthenticated user views an authenticated page, their browser will redirect to the login screen, just as expected.

  • When an authenticated user views a page, the user will initially be redirected to a login screen, but they’ll then be automatically redirected back to the requested page via navigation.navigate(redirectTo, { replace: true }) once the app has loaded.


    Redirect flow diagram


While the default behavior works, there are a couple ways in which it can be improved. In particular, you can prevent the loading screen from being flashed to logged in users before they’re redirected to the content.

Delaying render until authentication

When an authenticated user is redirected to the login screen, there’ll be a short delay between the page loading, and the user being redirected through to the page they requested. During this time, any statically rendered content for the login screen will be visible. As a result, authenticated users will see a brief flash of the login screen when they land on an authenticated URL.

This flash isn’t usually a problem. It only occurs if the user lands directly on a protected page, and won’t occur for subsequent navigation via pushState() within the same tab. Additionally, it won’t occur at all if the user lands on a public page before moving to a private page. However, if you really want to make things as polished as possible, there’s a simple solution: hide the login form until authentication has occurred.

There are a number of ways to do this, but the simplest is to add an isAuthenticationStateKnown boolean to your Navi app’s context, and set it to true once you’ve verified the user’s identity, or decided that the user is a guest. You can then pass the value through to your login component’s props to hide the login form until appropriate.

One thing to be careful of here is that ReactDOM.hydrate() can do funny things if the hydrated element doesn’t exactly match the statically rendered element. Because of this, you’ll always want to set isAuthenticationStateKnown to true after the initial render.

HTTP redirects

By default, navi-scripts renders redirects as a HTML file with a <meta>-based redirect. While this default will usually get the job done, it does have the issue of not being able to forward the URL ?search through to the target URL. As a result, to use ?search parameters with authenticated pages, you’ll need to set up HTTP redirects. For details, see the HTTP redirects section in the Static Rendering guide.