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.