Skip to content

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

LocalsProps
Go toRoot EJS template (views/app.ejs)Page components (React/Vue/Svelte)
WhenFirst visit only (full HTML response)Every visit (initial + XHR)
Use for<title>, <meta>, OG tags, structured dataUI data, user interactions
Accessible inEJS 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:

js
// 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:

js
// 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:

js
// 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:

html
<!-- 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):

  1. Global localssails.inertia.localGlobally('title', 'My App') (set once, applies everywhere)
  2. Request-scoped localssails.inertia.local('title', 'Dashboard') (set in hooks/middleware)
  3. Action localsreturn { 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:

js
// 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

js
// 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
      })
    }
  }
}
html
<!-- 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

js
// 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

js
// In a hook
sails.inertia.local('canonicalUrl', `https://myapp.com${req.path}`)
html
<link
  rel="canonical"
  href="<%= locals.canonicalUrl || 'https://myapp.com' %>"
/>

Reading locals

Use sails.inertia.getLocals() to read the merged result (global + request-scoped):

js
// Get all locals
const allLocals = sails.inertia.getLocals()

// Get a specific local
const title = sails.inertia.getLocals('title')

API reference

MethodScopeDescription
return { locals: { ... } }ActionSet locals from the action return value
sails.inertia.local(key, value)RequestSet a local for the current request
sails.inertia.localGlobally(key, value)GlobalSet a local for all requests
sails.inertia.getLocals(key?)MergedGet merged locals (global + request-scoped)

All open source projects are released under the MIT License.