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.
Static shared data
Section titled “Static shared data”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.
Dynamic shared data
Section titled “Dynamic shared data”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', },})Per-request sharing
Section titled “Per-request sharing”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.
Prop types
Section titled “Prop types”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:
| Helper | Description |
|---|---|
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
Section titled “Deferred props”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
Section titled “Optional props”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
Section titled “Merge props”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) }), })}Merge strategies
Section titled “Merge strategies”You can control how data is merged by passing an options object:
| Strategy | Description |
|---|---|
'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
Section titled “Once props”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 }), })}Once options
Section titled “Once options”| Option | Type | Description |
|---|---|---|
key | string | Custom cache key (defaults to the prop name) |
expiresAt | number | Unix timestamp (ms) after which the cached value is refreshed |
Always props
Section titled “Always props”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')) }), })}Full example
Section titled “Full example”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) }), }) }}Next steps
Section titled “Next steps”- Pages & Rendering — Learn about rendering pages and route decorators.
- Overview & Setup — Review the module configuration and template placeholders.