Skip to content

Shared Data & Props

Shared data lets you provide props that are available to every page without passing them explicitly in each controller method. Stratal supports both static values and dynamic resolvers that run on every request.

Pass simple key-value pairs in the sharedData option of InertiaModule.forRoot():

InertiaModule.forRoot({
rootView,
sharedData: {
appName: 'My App',
appVersion: '1.0.0',
},
})

These values are included as props on every page response.

For values that depend on the current request, use resolver functions. Each resolver receives the RouterContext and runs on every request:

InertiaModule.forRoot({
rootView,
sharedData: {
appName: 'My App',
currentUser: (ctx: RouterContext) => {
return ctx.get('user') ?? null
},
flash: (ctx: RouterContext) => {
return {
success: ctx.get('flashSuccess') ?? null,
error: ctx.get('flashError') ?? null,
}
},
locale: (ctx: RouterContext) => ctx.get('locale') ?? 'en',
},
})

You can also share data from within a controller or middleware using InertiaService.share():

import { Injectable } from 'stratal/di'
import { InertiaService } from '@stratal/inertia'
@Injectable()
export class NotificationsMiddleware {
constructor(private readonly inertia: InertiaService) {}
async handle(ctx: RouterContext, next: () => Promise<void>) {
const count = await this.getUnreadCount(ctx)
this.inertia.share('notificationCount', count)
await next()
}
}

Data shared this way is merged with the global sharedData for the current request only.

Beyond regular props, Inertia provides special prop helpers that control when and how data is loaded on the client. These are called on the ctx object within your controller methods:

HelperDescription
ctx.defer(cb, group?)Loaded after the initial page render via a follow-up request; can be grouped
ctx.optional(cb)Only included when explicitly requested by the client
ctx.merge(cb, options?)Merged with existing client-side data instead of replacing it
ctx.once(cb, options?)Sent on the first visit, cached on subsequent visits
ctx.always(cb)Always evaluated, even on partial reload requests

Deferred props are loaded after the initial page render, keeping the first paint fast. You can optionally group deferred props so they are fetched together in a single follow-up request:

@InertiaRoute()
index(ctx: RouterContext) {
return ctx.inertia('Dashboard/Index', {
// Sent immediately
user: currentUser,
// Loaded after page render
notifications: ctx.defer(async () => {
return await this.notificationService.getRecent()
}),
// Grouped — these two load in one request
stats: ctx.defer(async () => {
return await this.statsService.getSummary()
}, 'sidebar'),
recentActivity: ctx.defer(async () => {
return await this.activityService.getRecent()
}, 'sidebar'),
})
}

Optional props are excluded from the response by default. The client must explicitly request them using partial reload headers:

@InertiaRoute()
show(ctx: RouterContext) {
return ctx.inertia('Users/Show', {
user: { id: '1', name: 'Alice' },
// Only included when the client requests it
activityLog: ctx.optional(async () => {
return await this.activityService.getForUser('1')
}),
})
}

Merge props combine new data with the existing client-side data rather than replacing it. This is useful for infinite scroll, real-time feeds, or any scenario where you append to a list:

@InertiaRoute()
index(ctx: RouterContext) {
const page = Number(ctx.req.query('page') ?? '1')
return ctx.inertia('Posts/Index', {
posts: ctx.merge(async () => {
return await this.postService.paginate(page)
}),
})
}

You can control how data is merged by passing an options object:

StrategyDescription
'append'Append new items to the end of the existing array (default)
'prepend'Prepend new items to the beginning of the existing array
'deep'Deep merge objects; use matchOn to specify keys for matching array items
posts: ctx.merge(async () => {
return await this.postService.paginate(page)
}, { strategy: 'prepend' })

For deep merging with array item matching:

users: ctx.merge(async () => {
return await this.userService.getAll()
}, { strategy: 'deep', matchOn: ['id'] })

Once props are evaluated on the first visit and cached. Subsequent visits to the same page reuse the cached value. You can control expiration and cache keys:

@InertiaRoute()
index(ctx: RouterContext) {
return ctx.inertia('Settings/Index', {
// Cached indefinitely after first load
availableTimezones: ctx.once(async () => {
return await this.timezoneService.getAll()
}),
// Cached with a specific key and expiration
exchangeRates: ctx.once(async () => {
return await this.currencyService.getRates()
}, {
key: 'exchange-rates',
expiresAt: Date.now() + 3600_000, // 1 hour
}),
})
}
OptionTypeDescription
keystringCustom cache key (defaults to the prop name)
expiresAtnumberUnix timestamp (ms) after which the cached value is refreshed

Always props are evaluated on every request, including partial reloads. Use this for data that must never go stale:

@InertiaRoute()
show(ctx: RouterContext) {
return ctx.inertia('Orders/Show', {
order: { id: '1', total: 99.99 },
// Always fresh, even on partial reloads
currentBalance: ctx.always(async () => {
return await this.walletService.getBalance(ctx.get('userId'))
}),
})
}

Here is a controller that combines multiple prop types:

import { Controller, IController, RouterContext } from 'stratal/router'
import { InertiaRoute } from '@stratal/inertia'
import { Injectable } from 'stratal/di'
import { PostService } from './post.service'
import { CommentService } from './comment.service'
import { AnalyticsService } from './analytics.service'
@Injectable()
@Controller('/posts')
export class PostsController implements IController {
constructor(
private readonly posts: PostService,
private readonly comments: CommentService,
private readonly analytics: AnalyticsService,
) {}
@InertiaRoute()
async index(ctx: RouterContext) {
const page = Number(ctx.req.query('page') ?? '1')
return ctx.inertia('Posts/Index', {
// Standard prop — sent immediately
filters: { page, search: ctx.req.query('search') ?? '' },
// Merge prop — appends to existing list on pagination
posts: ctx.merge(async () => {
return await this.posts.paginate(page)
}),
// Deferred — loaded after initial render
trendingTags: ctx.defer(async () => {
return await this.posts.getTrendingTags()
}),
// Once — cached after first load
categories: ctx.once(async () => {
return await this.posts.getCategories()
}),
})
}
@InertiaRoute()
async show(ctx: RouterContext) {
const id = ctx.req.param('id')
return ctx.inertia('Posts/Show', {
post: await this.posts.findOrFail(id),
// Always fresh
viewCount: ctx.always(async () => {
return await this.analytics.getViewCount(id)
}),
// Optional — only loaded when requested
comments: ctx.optional(async () => {
return await this.comments.getForPost(id)
}),
})
}
}