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:
prerender.preload— Hydrates a TanstackQueryClientbefore rendering. Taco Fish prefetches menu results here so the client bundle can reuse them.PrerenderCacheContribution— Runs ahead of every prerendered request. You decide whether to cache, how to load data on a miss, and how to hydrate theQueryClienton a hit. The provided@authlance/prerender-cachepackage registersSequelizePrerenderCache, which persists payloads inTransientDatawith TTL-based eviction.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 anonymousAuthSessionscoped to the request path.queryClient: theQueryClientinstance that prerender hooks share.personalAccessToken: pulled from the PAT provided to the renderer config.paramsandquery: derived from the matched route and request search string.extraParams: an object the renderer mutates by invoking every boundRoutePrerenderContextContribution.route: the matchedRouteContribution.
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:
UserActionContributionreturns aUserActionconsumed byUsersComponent. Each action may supplyisVisible,getLabel, and receives asetNavigatecallback on mount so it can trigger route changes.GroupActionContributionreturnsGroupActionentries 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 currentAuthSessionplus 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:
- Multiple packages can register contributions for the same Symbol.
- The provider iterates all contributions and selects the one with the highest
getWeight()value. - Built-in defaults use weight
-1(or returnundefined), so any extension with weight0+wins. - 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).