All skills

Inertia.js server-side rendering, redirects, shared data, forms, validation, and client-side patterns for The Boring JavaScript Stack. Use this skill when building pages, handling form submissions, managing redirects, sharing data, or working with any Inertia.js feature in a Sails.js application.

Use this Skill: https://skilld.dev/gh/sailscastshq/boring-stack/inertia

This session only. Nothing lands on disk.

ruleslocals.md

≈2.5k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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 <%= 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
      }
    }
  }
}

From Hooks and Middleware

Use sails.inertia.local() for request-scoped locals:

// api/hooks/custom/index.js
module.exports = function defineCustomHook(sails) {
  return {
    routes: {
      before: {
        'GET /*': {
          skipAssets: true,
          fn: async function (req, res, next) {
            // Set a default title for all pages
            sails.inertia.local('title', 'My App')
            return next()
          }
        }
      }
    }
  }
}

Globally (App-Wide Defaults)

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.

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')

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. No typeof guards needed.

Precedence

Locals merge in this order (last wins):

  1. Global locals -- sails.inertia.localGlobally('title', 'My App') (set once, applies everywhere)
  2. Request-scoped locals -- sails.inertia.local('title', 'Dashboard') (set in hooks/middleware)
  3. Action locals -- return { locals: { title: 'Course: Intro to Sails' } } (set in the action)

This means an action's locals always win, 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' %>"
/>

Custom Fonts or Scripts Per Page

// An action that needs a code editor
return {
  page: 'editor/index',
  props: { document },
  locals: {
    extraHead:
      '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono">'
  }
}
<head>
  <!-- ... -->
  <%- locals.extraHead || '' %> <%- shipwright.styles() %>
</head>

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)

Common Patterns

Default Title with Page-Specific Override

// Hook: set default
sails.inertia.localGlobally('title', 'My App')

// Action: override for this page
return {
  page: 'settings/profile',
  props: { user },
  locals: { title: 'Profile Settings | My App' }
}

Conditional Locals

fn: async function () {
  const locals = { title: 'Pricing | My App' }

  // Only set OG image if we have a custom one
  if (sails.config.custom.pricingOgImage) {
    locals.ogImage = sails.config.custom.pricingOgImage
  }

  return { page: 'pricing', locals }
}

Source: SKILL.md on GitHub

3 warnings17d4 checks · Risk SAFE
  • Gen Agent Trust Hub17d

    This skill provides comprehensive documentation and guidelines for integrating Inertia.js within The Boring JavaScript Stack (Sails.js). No security vulnerabilities or malicious patterns were detected.

  • Socket17d

    1 alert: gptAnomaly

  • Snyk17d

    Risk: MEDIUM · 1 issue

  • Runlayer7mo

    16/16 files flagged

Signed by skilld at fb2b7e0. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 days ago.

Activeupdated 7 months ago
Other metadata
metadata
{
  "author": "sailscastshq",
  "version": "1.0.0",
  "tags": "inertia, sails, boring-stack, spa, forms, redirects, shared-data, props"
}

README badge

README badge for sailscastshq/boring-stack/inertia