Authlance Prerender & Runtime Contributions

Part of the Authlance Extension Guide.

This chapter picks up after routing. It shows how the @eltacofish wires the prerender pipeline, cache layer, and UI contribution points that ride on top of the route context.


Server-side Prerender & Cache Layer

Every route can opt into prerendering and caching through three building blocks that already exist in @authlance/core:

  1. prerender.preload — Hydrates a Tanstack QueryClient before rendering. Taco Fish prefetches menu results here so the client bundle can reuse them.
  2. PrerenderCacheContribution — Runs ahead of every prerendered request. You decide whether to cache, how to load data on a miss, and how to hydrate the QueryClient on a hit. The provided @authlance/prerender-cache package registers SequelizePrerenderCache, which persists payloads in TransientData with TTL-based eviction.
  3. RoutePrerenderContextContribution — (Optional) Adds derived parameters (for example, translating a slug into an ID) before the preload or cache logic runs.

Before either the preload hook or the cache layer runs, HtmlRenderer assembles a RoutePrerenderContext. During render it matches the incoming request to a RouteContribution, creates a fresh Tanstack QueryClient, initializes the redux store, and builds:

  • authContext: an anonymous AuthSession scoped to the request path.
  • queryClient: the QueryClient instance that prerender hooks share.
  • personalAccessToken: pulled from the PAT provided to the renderer config.
  • params and query: derived from the matched route and request search string.
  • extraParams: an object the renderer mutates by invoking every bound RoutePrerenderContextContribution.
  • route: the matched RouteContribution.

That context object is what runPrerenderCacheLayer, prerender.preload, and your hydrateQueryClient implementations receive, so anything you attach to extraParams is immediately visible to subsequent steps.

Helper utilities for collecting and replaying hydration data:

Example cache contribution for the Taco Fish truck’s event editor (still the same Taco team, just a richer slice of their stack):

import { injectable } from 'inversify'
import { PrerenderCacheContribution, RoutePrerenderContext, CachePayload } from '@authlance/core/lib/common/routes/routes'
import { QueryClient } from '@tanstack/react-query'
import { newTacoTruckApi } from '../../browser/common/taco-truck-sdk'
import type { TacoVenueDto, TacoTruckDto, TacoTicketStatusResponse } from '../../common/taco-truck-client'
import { EDIT_TACO_EVENT_CACHE_HYDRATED } from '../../common/prerender-flags'
import { applyHydrationEntries, collectHydrationEntries, parseHydrationEntries } from '@authlance/core/lib/node/utils'

@injectable()
export class TacoEventPrerenderCacheContribution implements PrerenderCacheContribution {
  readonly ttlMs = 30 * 60 * 1000 // 30 minutes of crispy cache

  buildCacheKey(context: RoutePrerenderContext): string | null {
    if (context.route?.path !== '/tacos/:truckId/:id') {
      return null
    }
    const truckId = context.params?.id
    if (!truckId) {
      return null
    }
    const truckKey = context.params?.truckId ?? 'unknown-truck'
    return `taco-event-details::${truckKey}::${truckId}`
  }

  async load(context: RoutePrerenderContext): Promise<CachePayload> {
    const pat = context.personalAccessToken
    if (!pat) {
      return { entries: [] }
    }

    const stagingClient = new QueryClient()
    const tacoTruckApi = newTacoTruckApi(pat)
    const eventId = context.params?.id
    const truckId = context.params?.truckId

    try {

      if (truckId) {
        await stagingClient.prefetchQuery(['taco-truck', truckId], async () => {
          try {
            const response = await tacoTruckApi.tacoTruckIdGet(truckId)
            return response.data
          } catch (error) {
            console.error('Error prefetching taco truck details (cache)', error)
            return undefined
          }
        })
      }

      if (eventId) {
        await stagingClient.prefetchQuery(['taco-event', eventId], async () => {
          try {
            const response = await tacoTruckApi.tacoEventsIdGet(eventId)
            return response.data
          } catch (error) {
            console.error('Error prefetching taco event details (cache)', error)
            return undefined
          }
        })
      }

      await stagingClient.prefetchQuery(['taco-event-sizes'], async () => {
        try {
          const response = await tacoTruckApi.tacoEventsSizesGet()
          return response.data
        } catch (error) {
          console.error('Error prefetching taco event sizes (cache)', error)
          return undefined
        }
      })

      await stagingClient.prefetchQuery(['has-al-pastor-ticket', eventId, undefined], async () => {
        const defaultResp: TacoTicketStatusResponse = { has_ticket: false }
        return defaultResp
      })

      await stagingClient.prefetchQuery(['available-taco-events-status'], async () => {
        try {
          const response = await tacoTruckApi.tacoEventsAvailabilityGet()
          return response.data
        } catch (error) {
          console.error('Error prefetching available taco events status (cache)', error)
          return undefined
        }
      })

      return collectTacoHydrationEntries(stagingClient)
    } finally {
      stagingClient.clear()
    }
  }

  async hydrateQueryClient(context: RoutePrerenderContext, payload: Partial<CachePayload>): Promise<string[]> {
    const entries = parseHydrationEntries(payload)
    const applied = applyHydrationEntries(context.queryClient, entries)
    const eventId = context.params?.id

    if (context.extraParams.tacoEventContext && context.extraParams.tacoTruckContext) {
      if (eventId) {
        const event = context.queryClient.getQueryData<TacoEventDto>(['taco-event', eventId])
        context.extraParams.tacoEventContext.setEvent(event)

        const hasTicketData = context.queryClient.getQueryData<TacoTicketStatusResponse>(['has-al-pastor-ticket', eventId, undefined])
        context.extraParams.tacoEventContext.setHasTicket(Boolean(hasTicketData?.has_ticket))
      } else {
        context.extraParams.tacoEventContext.setEvent(undefined)
        context.extraParams.tacoEventContext.setHasTicket(false)
      }

      const truckId = context.params?.truckId
      if (truckId) {
        const truck = context.queryClient.getQueryData<TacoVenueDto>(['taco-truck', truckId])
        context.extraParams.tacoTruckContext.setTruck(truck)
      } else {
        context.extraParams.tacoTruckContext.setTruck(undefined)
      }
    }

    if (entries.length > 0) {
      context.extraParams[EDIT_TACO_EVENT_CACHE_HYDRATED] = true
    }

    return applied
  }
}

Bind cache contributions in your backend container so HtmlRenderer uses them. Because the cache stores the already-hydrated data, most requests avoid hitting downstream APIs and databases unless the entry expired or you explicitly delete it through the PrerenderCache service.


User & Group Table Actions

@authlance/identity renders the user and group tables and defers contextual buttons to contribution interfaces:

  • UserActionContribution returns a UserAction consumed by UsersComponent. Each action may supply isVisible, getLabel, and receives a setNavigate callback on mount so it can trigger route changes.
  • GroupActionContribution returns GroupAction entries that show up inside the groups dropdown menu next to the built-in “Edit Group” and “View Members”.

Taco Fish adds a “Search Licenses” button to both tables:

@injectable()
class TacoUserAuditAction implements UserActionContribution {
  private navigate?: (path: string) => void;

  getAction(): UserAction {
    return {
      label: 'Search User Licenses',
      setNavigate: (nav) => (this.navigate = nav),
      action: (user) => this.navigate?.(`/licenses/user/${user.identity}`),
      isVisible: (_auth, user) => Boolean(user.identity),
    }
  }
}

@injectable()
class TacoGroupAuditAction implements GroupActionContribution {
  getAction(): GroupAction {
    return {
      label: 'Group Licenses',
      action: (_auth, group) => window.open(`/licenses/group/${group.name}`, '_blank'),
    }
  }
}

Bind both contributions (and ensure GroupActionsProviderImpl / UserActionsProviderImpl are active—the SaaS frontend already registers them) to have the menus update automatically.


Header & Sidebar Actions

Two additional contribution points control the chrome around every page:

  • MainActionContribution → header buttons rendered next to the page title. You receive the current AuthSession plus the active path so you can scope actions to specific routes.
  • SecondaryItemContribution → small icon links rendered near the profile avatar inside the sidebar. Useful for shortcuts such as “Kitchen Checklist” or “Pager Duty”.

You can always change how these actions are rendered by using your own layout.

Example Taco Fish main action:

@injectable()
class TacoNewLicenseAction implements MainActionContribution {
  getAction(auth: AuthSession, path: string): HeaderAction | undefined {
    if (!path.startsWith('/licenses')) {
      return undefined
    }
    return {
      id: 'create-license',
      label: 'Issue License',
      variant: 'default',
      icon: <PlusCircle />,
      action: () => auth.navigate?.('/licenses/create'),
    }
  }
}

Register these contributions in the frontend container. HomeHeader pulls them through useMainActionProvider and useSidebarSecondaryItemProvider, so the UI updates instantly once the container resolves your services.