Locals
Locals are variables passed to the root EJS template (views/app.ejs) during the initial full-page load. They let you set dynamic <title>, <meta>, Open Graph tags, and any other HTML that belongs in the server-rendered shell — without polluting your component props.
Why locals matter
Inertia apps are SPAs, but the first page load is a full server-rendered HTML response. Search engines, social media crawlers, and link previews all read this initial HTML. If your <head> has hardcoded defaults, every page looks the same to Google and Twitter.
Locals solve this. Each action can set page-specific meta tags that crawlers see on the initial load, while your React/Vue/Svelte components receive their data through props as usual.
Locals vs props
| Locals | Props | |
|---|---|---|
| Go to | Root EJS template (views/app.ejs) | Page components (React/Vue/Svelte) |
| When | First visit only (full HTML response) | Every visit (initial + XHR) |
| Use for | <title>, <meta>, OG tags, structured data | UI data, user interactions |
| Accessible in | EJS with <%= locals.variableName %> | usePage().props or component props |
On subsequent Inertia navigations (XHR), the server returns only the page JSON — no HTML is rendered, so locals have no effect. This is exactly what you want: meta tags only matter on the initial server response.
Setting locals from actions
Return a locals object alongside page and props:
// api/controllers/course/view-course.js
module.exports = {
inputs: {
slug: { type: 'string', required: true }
},
exits: {
success: { responseType: 'inertia' },
notFound: { responseType: 'notFound' }
},
fn: async function ({ slug }) {
const course = await Course.findOne({ slug })
if (!course) throw 'notFound'
return {
page: `courses/${slug}`,
props: { course },
locals: {
title: course.title,
description: course.description,
ogImage: course.thumbnailUrl
}
}
}
}Setting locals from hooks
Use sails.inertia.local() for request-scoped locals. This is safe for concurrent requests — each request gets its own isolated locals via AsyncLocalStorage, so two users hitting different pages at the same time won't see each other's titles:
// api/hooks/custom/index.js
module.exports = function defineCustomHook(sails) {
return {
routes: {
before: {
'GET /*': {
skipAssets: true,
fn: async function (req, res, next) {
sails.inertia.local('title', 'My App')
return next()
}
}
}
}
}
}Setting locals globally
Use sails.inertia.localGlobally() for defaults that apply to every request. Typically called during hook initialization:
// In a hook's initialize()
sails.inertia.localGlobally('title', 'Sailscasts')
sails.inertia.localGlobally(
'description',
'Screencasts for the calm JavaScript developer.'
)
sails.inertia.localGlobally('ogImage', 'https://sailscasts.com/images/meta.png')Global locals are the base layer. Request-scoped locals (from local()) override them, and action-level locals (from return { locals }) override both.
Why two methods?
localGlobally() writes to a shared object — it's the same value for every request. local() writes to an AsyncLocalStorage context scoped to the current request. Use localGlobally() for static defaults (app name, default OG image) and local() for anything derived from the request (canonical URL, user-specific data).
Using locals in EJS
Access your locals through EJS's built-in locals object. This is safe even when a local wasn't set — locals.title returns undefined instead of throwing a ReferenceError:
<!-- views/app.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= locals.title || 'My App' %></title>
<meta name="description" content="<%= locals.description || '' %>" />
<meta property="og:title" content="<%= locals.title || 'My App' %>" />
<meta property="og:description" content="<%= locals.description || '' %>" />
<meta
property="og:image"
content="<%= locals.ogImage || '/images/default-og.png' %>"
/>
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<%- shipwright.styles() %>
</head>
<body>
<div id="app" data-page="<%= JSON.stringify(page) %>"></div>
<%- shipwright.scripts() %>
</body>
</html>Why locals.title instead of bare title?
In EJS, referencing an undeclared variable (<%= title %>) throws a ReferenceError. But locals is a built-in EJS object that always exists — accessing a missing property on it returns undefined, which || handles cleanly.
Precedence
Locals merge in this order (last wins):
- Global locals —
sails.inertia.localGlobally('title', 'My App')(set once, applies everywhere) - Request-scoped locals —
sails.inertia.local('title', 'Dashboard')(set in hooks/middleware) - Action locals —
return { locals: { title: 'Course: Intro to Sails' } }(set in the action)
This means an action's locals always take priority, which is exactly what you want — a course page should show its own title, not the global default.
Real-world examples
Dynamic SEO for content pages
A course platform where every course and lesson page has unique meta tags for search engines and social sharing:
// api/controllers/course/view-lesson.js
module.exports = {
inputs: {
courseSlug: { type: 'string', required: true },
lessonSlug: { type: 'string', required: true }
},
exits: {
success: { responseType: 'inertia' },
notFound: { responseType: 'notFound' }
},
fn: async function ({ courseSlug, lessonSlug }) {
const lesson = await Lesson.findOne({ slug: lessonSlug }).populate('course')
if (!lesson) throw 'notFound'
return {
page: 'courses/lesson',
props: { lesson },
locals: {
title: `${lesson.title} — ${lesson.course.title}`,
description: `${lesson.title} — ${lesson.course.title}`,
ogImage: lesson.course.thumbnailUrl
}
}
}
}When someone shares a lesson link on Twitter or Slack, the preview card shows the lesson title, course name, and course thumbnail — not a generic "My App" card.
Blog posts with structured data
// api/controllers/blog/view-post.js
fn: async function ({ slug }) {
const post = await BlogPost.findOne({ slug }).populate('author')
if (!post) throw 'notFound'
return {
page: 'blog/show',
props: { post },
locals: {
title: `${post.title} | My Blog`,
description: post.excerpt,
ogImage: post.coverImageUrl,
jsonLd: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
image: post.coverImageUrl,
author: { '@type': 'Person', name: post.author.fullName },
datePublished: post.publishedAt
})
}
}
}<!-- views/app.ejs -->
<head>
<!-- ... other meta tags ... -->
<% if (locals.jsonLd) { %>
<script type="application/ld+json">
<%- locals.jsonLd %>
</script>
<% } %> <%- shipwright.styles() %>
</head>E-commerce product pages
// api/controllers/product/view-product.js
fn: async function ({ slug }) {
const product = await Product.findOne({ slug })
if (!product) throw 'notFound'
return {
page: 'products/show',
props: { product },
locals: {
title: `${product.name} — $${product.price} | My Store`,
description: product.shortDescription,
ogImage: product.images[0]?.url
}
}
}Per-page canonical URLs
// In a hook
sails.inertia.local('canonicalUrl', `https://myapp.com${req.path}`)<link
rel="canonical"
href="<%= locals.canonicalUrl || 'https://myapp.com' %>"
/>Reading locals
Use sails.inertia.getLocals() to read the merged result (global + request-scoped):
// Get all locals
const allLocals = sails.inertia.getLocals()
// Get a specific local
const title = sails.inertia.getLocals('title')API reference
| Method | Scope | Description |
|---|---|---|
return { locals: { ... } } | Action | Set locals from the action return value |
sails.inertia.local(key, value) | Request | Set a local for the current request |
sails.inertia.localGlobally(key, value) | Global | Set a local for all requests |
sails.inertia.getLocals(key?) | Merged | Get merged locals (global + request-scoped) |