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.


Group Selection Behavior

When a user picks a group from the group selector, the default behavior calls authSession.changeTargetGroup(group.name) and navigates to /. You can override this with GroupSelectionContribution:

export const GroupSelectionContribution = Symbol('GroupSelectionContribution')

export interface GroupSelectionHandler {
    onGroupSelected(group: Group, authSession: AuthSession, navigate: NavigateFunction): void
    getWeight(): number
}

export interface GroupSelectionContribution {
    getHandler(): GroupSelectionHandler
}

The provider selects the handler with the highest weight. The built-in default has weight -1, so any contribution with weight 0 or higher takes over. This is useful when your app needs to perform side effects on group change — for example, setting a cookie or redirecting to a group-specific dashboard:

@injectable()
class CustomGroupSelectionHandler implements GroupSelectionContribution {
  getHandler(): GroupSelectionHandler {
    return {
      onGroupSelected(group: Group, authSession: AuthSession, navigate: NavigateFunction): void {
        // Set a cookie for server-side group context
        document.cookie = `active_group=${group.name}; path=/`
        authSession.changeTargetGroup(group.name)
        navigate(`/dashboard/${group.name}`)
      },
      getWeight(): number {
        return 0
      },
    }
  }
}

Group Selection UI

GroupSelectionUIContribution lets you replace the entire group selection view shown to users when they need to choose a group (e.g., after registration or when switching groups). This is different from the behavior hook above — it controls what the user sees, not what happens after they pick.

export const GroupSelectionUIContribution = Symbol('GroupSelectionUIContribution')

export interface GroupSelectionUIContribution {
    getContent(authContext: AuthSession): React.ReactElement
    getWeight(): number
}

The highest-weight contribution wins. If no contribution is registered, the default group list is used. Use this to add tier selection steps, onboarding wizards, or custom group creation flows:

@injectable()
class TierBasedGroupSelectionUI implements GroupSelectionUIContribution {
  getContent(authContext: AuthSession): React.ReactElement {
    return React.createElement(MyTierSelectionWizard, { auth: authContext })
  }

  getWeight(): number {
    return 10
  }
}

Tier Selection UI

When the built-in group creation flow shows the tier selection step (for paid groups), you can customize it with TierSelectionUIContribution:

export const TierSelectionUIContribution = Symbol('TierSelectionUIContribution')

export interface TierSelectionUIContribution {
    getContent(props: TierSelectionStepProps): React.ReactElement
    getWeight(): number
}

TierSelectionStepProps includes the list of available PaymentTierDto items and callbacks for selection. The default UI renders a grid of TierCard components. Override it when you need custom pricing displays, comparison tables, or platform-specific tier filtering:

@injectable()
class CustomTierSelection implements TierSelectionUIContribution {
  getContent(props: TierSelectionStepProps): React.ReactElement {
    // Render a custom pricing page instead of the default tier cards
    return React.createElement(CustomPricingPage, {
      tiers: props.tiers,
      onSelect: props.onTierSelected,
    })
  }

  getWeight(): number {
    return 0
  }
}

Activate Group Text

ActivateGroupTextContribution customizes the title and description shown during the group activation step (after a user selects a tier and before they are redirected to checkout):

export const ActivateGroupTextContribution = Symbol('ActivateGroupTextContribution')

export interface ActivateGroupTextContribution {
    getTitle(paymentTier: PaymentTierDto): string
    getDescription(paymentTier: PaymentTierDto): React.ReactElement
    getWeight(): number
}

The provider returns undefined when no contributions are registered, falling back to the built-in copy. Use this to tailor messaging for your product:

@injectable()
class CustomActivateText implements ActivateGroupTextContribution {
  getTitle(paymentTier: PaymentTierDto): string {
    return `Start your ${paymentTier.tierName} plan`
  }

  getDescription(paymentTier: PaymentTierDto): React.ReactElement {
    return React.createElement('p', null,
      `You're about to create a workspace on the ${paymentTier.tierName} plan ` +
      `for up to ${paymentTier.maxMembers} members.`
    )
  }

  getWeight(): number {
    return 0
  }
}

Registration Footer

RegistrationFooterContribution injects content below the registration form. The highest-weight contribution wins. Use it for terms of service links, legal disclaimers, or promotional banners:

export const RegistrationFooterContribution = Symbol('RegistrationFooterContribution')

export interface RegistrationFooterContribution {
    getFooter(): React.ReactElement
    getWeight(): number
}

Example:

@injectable()
class TermsFooter implements RegistrationFooterContribution {
  getFooter(): React.ReactElement {
    return React.createElement('p', { className: 'text-sm text-muted-foreground mt-4' },
      'By signing up, you agree to our ',
      React.createElement('a', { href: '/terms' }, 'Terms of Service'),
      ' and ',
      React.createElement('a', { href: '/privacy' }, 'Privacy Policy'),
      '.'
    )
  }

  getWeight(): number {
    return 0
  }
}

Weight-based Provider Pattern

The new contribution points (GroupSelectionContribution, GroupSelectionUIContribution, TierSelectionUIContribution, ActivateGroupTextContribution, RegistrationFooterContribution) all follow the same weight-based selection pattern:

  1. Multiple packages can register contributions for the same Symbol.
  2. The provider iterates all contributions and selects the one with the highest getWeight() value.
  3. Built-in defaults use weight -1 (or return undefined), so any extension with weight 0+ wins.
  4. If two contributions have the same weight, the last one registered wins (container binding order).

This differs from the array-based pattern used by UserActionContribution and GroupActionContribution, where all contributions are collected and rendered together. Choose the right pattern based on whether you want additive behavior (actions) or exclusive override (UI replacements).