inertia-sails
v1.6.0
Published
The Sails adapter for Inertia.
Readme
inertia-sails
The official Inertia.js adapter for Sails.js, powering The Boring JavaScript Stack.
Installation
npm install inertia-sailsOr use create-sails to scaffold a complete app:
npx create-sails my-appQuick Start
1. Configure Inertia
// config/inertia.js
module.exports.inertia = {
rootView: 'app', // views/app.ejs
version: 1 // Asset version for cache busting
}2. Create a root view
<!-- views/app.ejs -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%- shipwright.styles() %>
</head>
<body>
<div id="app"></div>
<script type="application/json" data-page="app">
<%- JSON.stringify(page).replace(/</g, '\\u003c') %>
</script>
<%- shipwright.scripts() %>
</body>
</html>3. Create an action
// api/controllers/dashboard/view-dashboard.js
module.exports = {
exits: {
success: { responseType: 'inertia' }
},
fn: async function () {
return {
page: 'dashboard/index',
props: {
stats: await Stats.find()
}
}
}
}API Reference
Responses
responseType: 'inertia'
Return an Inertia page response:
return {
page: 'users/index', // Component name
props: { users: [...] }, // Props passed to component
locals: { title: '...' } // Locals for root EJS template
}responseType: 'inertiaRedirect'
Return a URL string to redirect:
return '/dashboard'inertiaRedirect performs an Inertia location visit. It does not return a
page object, so sails.inertia.preserveFragment() does not apply to this
response type. If you already know the fragment you want, include it in the
returned URL.
Preserving URL fragments
When a standard Inertia redirect should carry the current hash to the next page, mark the redirect before returning the URL:
sails.inertia.preserveFragment()
return '/articles/new-slug'If the user started from /articles/old-slug#comments, the Inertia client can
carry #comments to the redirected page.
Sharing Data
share(key, value)
Share data with the current request (request-scoped):
sails.inertia.share('flash', { success: 'Saved!' })shareGlobally(key, value)
Share data across all requests (app-wide):
// In hook initialization
sails.inertia.shareGlobally('appName', 'My App')local(key, value)
Set a local variable for the root EJS template:
sails.inertia.local('title', 'Dashboard')Once Props (Cached)
Cache expensive props across navigations. The client tracks cached props and skips re-fetching.
once(callback)
// In custom hook
sails.inertia.share(
'loggedInUser',
sails.inertia.once(async () => {
return await User.findOne({ id: req.session.userId })
})
)Chainable methods:
.as(key)- Custom cache key.until(seconds)- TTL expiration.fresh(condition)- Force refresh
sails.inertia
.once(() => fetchPermissions())
.as('user-permissions')
.until(3600) // Cache for 1 hour
.fresh(req.query.refresh === 'true')shareOnce(key, callback)
Shorthand for share() + once():
sails.inertia.shareOnce('countries', () => Country.find())refreshOnce(keys)
Force refresh cached props from an action:
// After updating user profile
await User.updateOne({ id: userId }).set({ fullName })
sails.inertia.refreshOnce('loggedInUser')Flash Messages
One-time messages that don't persist in browser history:
sails.inertia.flash('success', 'Profile updated!')
sails.inertia.flash({ error: 'Failed', field: 'email' })Access in your frontend via page.props.flash.
Error Pages
In development, 500-level HTML responses render a rich Youch error page with the stack trace and sanitized request metadata. For Inertia visits, the client will show that HTML in its development error modal.
In production, inertia-sails renders the configured Inertia error page for
403, 404, 500, and 503 responses by default. The page receives
status, title, and message props:
<!-- assets/js/pages/error.vue -->
<script setup>
import { Head, Link } from '@inertiajs/vue3'
defineProps({
status: Number,
title: String,
message: String
})
</script>
<template>
<Head :title="`${status} ${title}`" />
<main>
<p>Status {{ status }}</p>
<h1>{{ title }}</h1>
<p>{{ message }}</p>
<Link href="/">Go home</Link>
</main>
</template>Wire Sails' standard responses through the hook so framework-level 403/404/500 responses use the same policy:
// api/responses/serverError.js
module.exports = function serverError(error) {
return this.req._sails.inertia.handleServerError(this.req, this.res, error)
}
// api/responses/notFound.js
module.exports = function notFound(error) {
return this.req._sails.inertia.handleErrorPage(this.req, this.res, {
statusCode: 404,
error
})
}
// api/responses/forbidden.js
module.exports = function forbidden(error) {
return this.req._sails.inertia.handleErrorPage(this.req, this.res, {
statusCode: 403,
error
})
}Hybrid apps can keep classic Sails EJS error views by disabling the Inertia error page:
// config/inertia.js
module.exports.inertia = {
errorPage: false
}Precognition
Inertia v3 forms can validate against server-owned Sails rules before the real
submit runs. inertia-sails handles the Precognition response headers and
returns validation failures as 422 JSON instead of redirecting through the
normal session-backed Inertia error flow.
Use withPrecognition() on the client:
const form = useForm({
email: null
}).withPrecognition('post', '/forgot-password')Then validate a field when the user leaves it:
<InputEmail v-model="form.email" @blur="form.validate('email')" />On the server, add a small custom response so successful Precognition checks
can exit before side effects without going through the action's normal
success response type:
// api/responses/precognitionSuccess.js
module.exports = function precognitionSuccess() {
return this.req._sails.inertia.handlePrecognitionSuccess(this.req, this.res)
}Then use a named exit in the action:
exits: {
success: {
responseType: 'redirect'
},
precognitionSuccess: {
responseType: 'precognitionSuccess'
}
},
fn: async function ({ email }, exits) {
if (sails.inertia.isPrecognitive(this.req)) {
return exits.precognitionSuccess()
}
await sendPasswordResetEmail(email)
return '/check-email'
}For custom database-backed checks, use shouldValidate() so expensive rules
only run when the client asked for that field:
if (sails.inertia.shouldValidate('email', this.req)) {
const existingUser = await User.findOne({ email })
if (existingUser) {
throw {
badSignupRequest: {
problems: [{ email: 'An account with this email already exists.' }]
}
}
}
}Available helpers:
sails.inertia.isPrecognitive(req?)sails.inertia.validateOnly(req?)sails.inertia.shouldValidate(field, req?)sails.inertia.handlePrecognitionSuccess(req, res)for custom responses
Deferred Props
Load props after initial page render:
return {
page: 'dashboard',
props: {
// Loads immediately
user: currentUser,
// Loads after render
analytics: sails.inertia.defer(async () => {
return await Analytics.getExpensiveReport()
})
}
}Deferred props can also be rescued when a non-critical callback fails:
return {
page: 'dashboard',
props: {
analytics: sails.inertia
.defer(async () => {
return await Analytics.getExpensiveReport()
})
.rescue()
}
}Or pass the rescue option inline:
return {
page: 'dashboard',
props: {
analytics: sails.inertia.defer(
async () => {
return await Analytics.getExpensiveReport()
},
{ rescue: true }
)
}
}When a rescued deferred prop throws, it is omitted from props and its key is
reported in rescuedProps, allowing the client <Deferred> component to show
its rescue slot instead of failing the whole deferred response.
Merge Props
Merge with existing client-side data (useful for infinite scroll):
// Shallow merge
messages: sails.inertia.merge(() => newMessages)
// Prepend new items instead of appending
notifications: sails.inertia.merge(() => newNotifications).prepend()
// Merge a nested array inside a paginated object
users: sails.inertia.merge(() => paginatedUsers).append('data')
// Match existing items by ID when merging
users: sails.inertia
.merge(() => paginatedUsers)
.append('data', {
matchOn: 'id'
})
// Deep merge (nested objects)
settings: sails.inertia.deepMerge(() => updatedSettings)
// Deep merge with item matching
chat: sails.inertia.deepMerge(() => chatState).matchOn('messages.id')Infinite Scroll
Paginate data with automatic merge behavior. Works with Inertia's <InfiniteScroll> component:
// Controller
const page = this.req.param('page', 0)
const perPage = 20
const invoices = await Invoice.find().paginate(page, perPage)
const total = await Invoice.count()
return {
page: 'invoices/index',
props: {
invoices: sails.inertia.scroll(() => invoices, {
page,
perPage,
total,
wrapper: 'data' // Wraps in { data: [...], meta: {...} }
})
}
}<!-- Vue component -->
<script setup>
import { InfiniteScroll } from '@inertiajs/vue3'
defineProps({ invoices: Object })
</script>
<template>
<InfiniteScroll data="invoices">
<div v-for="invoice in invoices.data" :key="invoice.id">
{{ invoice.invoiceNumber }}
</div>
</InfiniteScroll>
</template>scroll() targets the wrapped array for merging, such as invoices.data, and follows Inertia's infinite-scroll merge intent header so previous-page requests prepend while next-page requests append.
History Encryption
Encrypt sensitive data in browser history:
sails.inertia.encryptHistory() // Enable for current request
sails.inertia.clearHistory() // Clear history stateRoot View
Change the root template per-request:
sails.inertia.setRootView('auth') // Use views/auth.ejsBack Navigation
Get the referrer URL for redirects:
return sails.inertia.back('/dashboard') // Fallback if no referrerOptional Props
Props only included when explicitly requested via partial reload:
categories: sails.inertia.optional(() => Category.find())Always Props
Props included even in partial reloads:
csrf: sails.inertia.always(() => this.req.csrfToken())Custom Hook Example
Share user data across all authenticated pages using once() for caching:
// api/hooks/custom/index.js
module.exports = function defineCustomHook(sails) {
return {
routes: {
before: {
'GET /*': {
skipAssets: true,
fn: async function (req, res, next) {
if (req.session.userId) {
sails.inertia.share(
'loggedInUser',
sails.inertia.once(async () => {
return await User.findOne({ id: req.session.userId }).select([
'id',
'email',
'fullName',
'avatarUrl'
])
})
)
}
return next()
}
}
}
}
}
}Custom Responses
Copy these to api/responses/:
- inertia.js - Handle Inertia page responses
- inertiaRedirect.js - Handle Inertia redirects
- badRequest.js - Validation errors with redirect back
- serverError.js - Error modal in dev, graceful redirect in prod
- notFound.js - 404 status pages
- forbidden.js - 403 status pages
Architecture
inertia-sails uses AsyncLocalStorage for request-scoped state, preventing data leaks between concurrent requests. This is critical for share(), flash(), setRootView(), and other per-request APIs.
Configuration
// config/inertia.js
module.exports.inertia = {
// Root EJS template (default: 'app')
rootView: 'app',
// Asset version for cache busting (optional - auto-detected by default)
// version: 'custom-version',
// History encryption settings
history: {
encrypt: false
},
// Production status page component.
// Set to false to keep classic Sails EJS error views.
errorPage: 'error',
errorStatuses: [403, 404, 500, 503],
// Inertia DevTools are enabled automatically in development.
devtools: {
enabled: null
}
}Inertia DevTools
inertia-sails implements the
Inertia v3 DevTools protocol.
Install the official browser extension and use an Inertia client adapter version
3.6 or newer. Initial page loads and subsequent visits are discovered
automatically; application root views do not need a DevTools script tag.
The templates use the client adapters' import.meta.env.DEV default, which
Rsbuild supplies in development; an explicit dev option is not required.
DevTools recording defaults to the Sails development environment. Recorded
entries are atomic JSON files under .tmp/inertia-devtools, expire after 24
hours, and are limited to 100 entries per browser tab.
GET /_inertia/devtools/entries/:id retrieves one entry;
GET /_inertia/devtools/entries returns the stored buffer newest first.
Both endpoints use the same authorization rules.
// config/inertia.js
module.exports.inertia = {
devtools: {
// null: enable only when sails.config.environment === 'development'
enabled: null,
except: ['/_inertia/devtools*'],
storage: {
path: '.tmp/inertia-devtools',
ttl: 24, // hours
pruneInterval: 300_000,
limit: 100,
circuitBreaker: 30_000
},
redact: {
keys: ['password', 'token', 'api_key', 'secret'],
headers: ['cookie', 'set-cookie', 'authorization', 'x-api-key']
},
pages: {
paths: ['assets/js/pages'],
extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte']
},
bodyLimit: 256_000
}
}The default redaction lists include common credentials and security headers.
Supplying redact.keys or redact.headers replaces its corresponding default
list, so include every application-specific sensitive name.
Enabling DevTools outside development requires an explicit request authorizer.
Without one, the entry endpoint returns 403:
module.exports.inertia = {
devtools: {
enabled: true,
authorize: async (req) => {
return req.session.userId && (await User.isAdmin(req.session.userId))
}
}
}The recorder never reads uploaded file streams or persists raw uploaded files. Binary, streamed, multipart, unserializable, and oversized bodies are represented as omitted values. Recording and storage failures never change the application response.
Automatic Asset Versioning
inertia-sails automatically handles asset versioning:
With Shipwright: Reads
.tmp/public/manifest.jsonand generates an MD5 hash. Version changes when any bundled asset changes.Without Shipwright: Falls back to server startup timestamp, ensuring fresh assets on each restart.
You can override this with a custom version if needed:
// config/inertia.js
module.exports.inertia = {
version: 'v2.1.0' // Or a function: () => myCustomVersion()
}