Grails plugin for using Inertia.js to build single-page apps without building an API.
Inertia.js lets you, in its own words, “quickly build modern single-page React, Vue and Svelte apps using classic server-side routing and controllers”.
Using Inertia.js allows using your favorite MVC server-side framework (Grails obviously) with your favorite client-side SPA framework - no need to build a separate API.
Ping CRM is an application using this plugin
Source | Live Demo
Note
This is a port to Grails/Groovy of the original Ping CRM demo written in Laravel/PHP.
If you don't have an application already:
grails create-app myapp
cd myapp
Add the plugin dependency to the project:
// myapp/build.gradle
dependencies {
//...
// Replace $inertiaPluginVersion with a suitable release version for your project, or define it in ~/myapp/gradle.properties
implementation "io.github.matrei:grails-inertia:$inertiaPluginVersion"
//...
}Note
For Grails 8/Java 21 - use the latest version of the plugin.
For Grails 7/Java 17 - use version 3.
For Grails 6/Java 11 - use version 2 (io.github.matrei:grails-inertia-plugin).
For a Grails 5/Java 8 - use version 1 (io.github.matrei:grails-inertia-plugin).
To add the client dependencies and workflow to a Grails project, create the following files: (Vue 3 example)
// myapp/package.json (versions checked 2026-08-20){
"name": "myapp",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vite --port 3000",
"build": "vite build && vite build --outDir src/main/resources/ssr --ssr src/main/javascript/ssr.js"
},
"dependencies": {
"vue": "^3.5.41",
"@inertiajs/vue3": "^3.7.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.8",
"@inertiajs/vite": "^3.7.0",
"vite": "^8.2.2"
}
}// myapp/vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import inertia from '@inertiajs/vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ command }) => ({
base: command === 'serve' ? '' : '/static/dist/',
publicDir: false,
build: {
manifest: true,
outDir: 'src/main/resources/public/dist',
assetsDir: 'js',
rollupOptions: {
input: 'src/main/javascript/main.js'
}
},
plugins: [vue(), inertia({ ssr: false })],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src/main/javascript', import.meta.url))
}
},
server: {
// Needed for changes to picked up when running in WSL on Windows
watch: {
usePolling: true
}
}
}))Note
The plugin reads the Vite manifest from classpath:public/dist/.vite/manifest.json, which matches the outDir above.
If you change outDir, set inertia.manifest.location in application.yml to the new location.
// myapp/src/main/javascript/main.js
import { createApp, createSSRApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
createInertiaApp({
pages: './Pages',
setup ({el, App, props, plugin}) {
const app = el.dataset.serverRendered === 'true'
? createSSRApp({ render: () => h(App, props) })
: createApp({ render: () => h(App, props) })
app
.use(plugin)
.mount(el)
}
})// myapp/src/main/javascript/ssr.js (Optional, for Server Side Rendering)
import { createSSRApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import createServer from '@inertiajs/vue3/server'
import { renderToString } from '@vue/server-renderer'
createServer(page =>
createInertiaApp({
page,
render: renderToString,
pages: './Pages',
setup({ App, props, plugin }) {
return createSSRApp({
render: () => h(App, props)
})
.use(plugin)
}
})
)
It can be a good idea to add the following entries to your .gitignore
# myapp/.gitignore
# ...
node_modules
src/main/resources/public/dist
And run the following command to install the client dependencies:
npm installIn your Grails controllers, you can now select which JavaScript Page Component to render and pass the values of the props to it.
// myapp/grails-app/controllers/myapp/BookController.groovy
package myapp
class BookController {
def index() {
def books = ['Grails in Action', 'Programming Grails', 'The Definitive Guide to Grails 2']
renderInertia('Books/Index', [books: books])
}
}The name of the page component is its path below src/main/javascript/Pages, without the .vue extension, so
Books/Index renders src/main/javascript/Pages/Books/Index.vue. The name must match the file name exactly,
including upper and lower case, also on file systems that ignore case. A page that does not exist fails with
Page not found: Books/Index.
Here is an example Vue 3 Single File Component to that will render the books as a list.
<!-- myapp/src/main/javascript/Pages/Books/Index.vue -->
<script setup>
defineProps({ books: Array })
</script>
<template>
<ul>
<li v-for="book in books">{{ book }}</li>
</ul>
</template>
For development with Hot Module Replacement of the application run: (in separate terminals)
npm run serve./gradlew bootRun
For production or test, first build the production version of your JavaScript app...
npm run build
...and then run whatever you want to do:
./gradlew integrationTest
./gradlew bootJarIn the development environment, the page loads the JavaScript from the Vite dev server. In all other environments,
it loads the assets built by Vite, as listed in the Vite manifest. The dev server, its URL and the entry point of the
JavaScript application can be configured:
# myapp/grails-app/conf/application.yml
inertia:
vite:
devServer:
enabled: true # Defaults to true in development and false otherwise
url: 'http://localhost:3000' # Optional, this is the default value
entry: 'src/main/javascript/main.js' # Optional, this is the default valueTo use the dev server in another environment as well, for example in test, enable it for that environment:
# myapp/grails-app/conf/application.yml
environments:
test:
inertia:
vite:
devServer:
enabled: trueThe favicon can be configured without overriding the Inertia HTML template.
Place the icon in src/main/resources/public/, which Grails serves under /static/, and point the config at it:
# myapp/grails-app/conf/application.yml
inertia:
favicon: '/static/favicon.svg' # served from src/main/resources/public/favicon.svgA root-relative value (starting with a single /) is resolved against the application's context path.
Any other value, such as an absolute URL, is used as-is.
The link is omitted when no favicon is configured.
The page title shown until a page component sets its own defaults to the application name (info.app.name),
which the Grails build sets to the name of the project. A different title can be configured:
# myapp/grails-app/conf/application.yml
inertia:
title: 'My App'The title is omitted when a server-side rendered page provides its own title.
The lang attribute of the page is the locale Grails resolves for the request, so it follows a language chosen with
the ?lang= parameter. By default, Grails falls back to the language of the browser when none has been chosen.
An application in a single language should use a fixed locale instead:
# myapp/grails-app/conf/application.yml
grails:
i18n:
localeResolver: fixed
default:
locale: enThe lang attribute is set when the page is loaded. When the language is switched with an Inertia visit, the
application needs to update document.documentElement.lang itself, or switch the language with a full page load.
Instead of writing the URLs of your controller actions in the page components, you can generate them from the URL mappings of the application as a JavaScript module, with an object for each controller, named after its class, and a function for each action:
// myapp/grails-app/controllers/myapp/UrlMappings.groovy
"/books/$id" (controller: 'book', action: [GET: 'show', PUT: 'update', POST: 'update', DELETE: 'delete'])
name bookList: '/books' (controller: 'book', action: 'index')import { BookController, named } from '@/routes'
BookController.show(1) // { url: '/books/1', method: 'get' }
BookController.update(1) // { url: '/books/1', method: 'put' }
BookController.update.post(1) // { url: '/books/1', method: 'post' }
BookController.show.url(1) // '/books/1'
BookController.index({ page: 2 }) // { url: '/books?page=2', method: 'get' }
named.bookList() // { url: '/books', method: 'get' }An action function takes the URL parameters as a single value, as an array in the order of the URL, or as an object by
name, in which the names that are not part of the URL become the query string. The URLs include the context path of
the application. The routes of named URL mappings are also exported by name in named. An action that has no URL
mapping of its own gets its route from a mapping such as "/$controller/$action?/$id?".
The context path is exported as contextPath, for the URLs that are not controller actions, such as those handled by
Spring Security:
import { contextPath } from '@/routes'
form.post(`${contextPath}/login/authenticate`)An action's methods are those of its URL mapping. When the mapping accepts any method, they are taken from the
allowedMethods of the controller, such as static allowedMethods = [restore: 'PUT'], and otherwise default to get.
The first method is the default, and each method has a function of its own, such as BookController.update.post.
Inertia accepts the returned objects wherever it takes a URL, and uses their method:
<Link :href="BookController.show(bookId)">Show</Link>
<Form :action="BookController.update(bookId)">...</Form>router.visit(BookController.delete(bookId))
form.submit(BookController.update(bookId))Generate the module with the inertiaRoutes task, which starts the application to read its URL mappings:
./gradlew inertiaRoutes
./gradlew inertiaRoutes -Pargs="--output=src/main/javascript/generated/routes.js"With Grails 8.0.0 and the Gradle configuration cache enabled, run it with --no-configuration-cache.
The module can also be written each time the application starts, which is useful in development:
# myapp/grails-app/conf/application.yml
inertia:
routes:
generate: true # Defaults to false
output: 'src/main/javascript/routes.js' # Optional, this is the default value, also used by the inertiaRoutes task
only: # Optional, write only the matching routes
- 'book.*'
- 'bookList'
except: # Optional, leave out the matching routes
- 'errorHandling.*'only and except take patterns of route names, in which * matches any characters. A route is named
controller.action by the logical name of the controller, as in the URL mappings, such as book.update, or by its URL
mapping when the mapping is named. When only is set, only the matching routes are
written, and the routes matching except are then left out. Both also take a comma-separated string.
When the application is deployed with a context path (server.servlet.context-path), the plugin prefixes it to
the URLs of the built JavaScript and CSS files. Vite also writes the base path into the built files, so it must
include the context path as well:
// myapp/vite.config.js
export default defineConfig(({ command }) => ({
base: command === 'serve' ? '' : '/myapp/static/dist/', // With server.servlet.context-path: /myapp
// ...
}))To enable server-side rendering, make sure Node.js 22 or later is installed and available on the PATH.
The Grails adapter starts the production SSR bundle when inertia.ssr.enabled is true. Build the client and SSR
bundles before starting the Grails application:
npm run buildThen add the following to your application.yml:
inertia:
ssr:
enabled: true # Defaults to false
url: 'http://localhost:13714/render' # Optional, this is the default value
bundle: 'src/main/resources/ssr/ssr.mjs' # Optional, this is the default value
connect-timeout: 1000 # Optional, this is the default value, in milliseconds
read-timeout: 5000 # Optional, this is the default value, in millisecondsThe @inertiajs/vite plugin is configured with ssr: false in this example because the Grails adapter manages the
production SSR process. SSR failures fall back to normal client-side rendering.
- The plugin requires Grails 8 and Java 21. Applications on Grails 7 can keep using version 3.
- The page requires the Inertia 3 client. The page data is now rendered in a
<script type="application/json">element, which the Inertia 3 client reads, instead of thedata-pageattribute of the app element. Upgrade the@inertiajspackages to version 3 (see Inertia 3 support). - Outside the
developmentenvironment, the page now loads the built assets by default, including intestand in custom environments. Before, it loaded the JavaScript from the Vite dev server athttp://localhost:3000in every environment exceptproduction. The Vite manifest was already required outsidedevelopment, so tests that render the page need the built assets, for example by makingprocessResourcesdepend on the Vite build, or they can keep using the dev server by settinginertia.vite.devServer.enabled: truefortest(see Vite dev server). - The page now has a title until a page component sets its own: the application name, or
inertia.titlewhen it is configured (see Title). Before, the page had no title. - The
langattribute of the page is now the locale resolved for the request, instead of alwaysen. By default, Grails falls back to the language of the browser, so an application in a single language should use a fixed locale (see Language). - With a context path, the plugin now prefixes it to the URLs of the built JavaScript and CSS files, so a custom template or other workaround for the context path is no longer needed (see Context path).
- A custom
grails-app/views/inertia/html.gspnow gets the built assets of the entry point asinertiaViteEntry, with itsfile,cssandpreloads, instead of the whole Vite manifest asinertiaManifest. The entry is resolved from the manifest when the application starts, so a manifest without the entry fails the startup.
The adapter supports the Inertia v3 page protocol, including:
- Partial reloads with
only,except, and nested dot-notation paths. - Lazy, optional, always, and deferred props.
- Merge, prepend, deep-merge, and once props.
- Infinite-scroll metadata and matching keys.
- Shared props, flash data, and page metadata.
- Fragment redirects and asset-version mismatch responses.
For example:
renderInertia('Users/Index', [
users: Inertia.merge({ loadUsers() }),
permissions: Inertia.defer({ loadPermissions() }),
settings: Inertia.once(loadSettings())
])Inertia 3 also removes Axios from the client package, renames several client events, and replaces
router.cancel() with router.cancelAll(). See the Inertia upgrade guide
when upgrading an existing frontend.
Page history and redirect behavior can be controlled from a Grails controller. These controls apply to the page returned by the current request:
class AccountController {
def settings() {
encryptInertiaHistory()
renderInertia('Account/Settings', [account: currentAccount()])
}
def reset() {
clearInertiaHistory()
renderInertia('Account/Reset')
}
def rename() {
preserveInertiaFragment()
redirect(uri: '/account/settings')
}
}The equivalent static methods are Inertia.encryptHistory(), Inertia.clearHistory(), and
Inertia.preserveFragment(). They emit the v3 encryptHistory, clearHistory, and preserveFragment page
properties when enabled. Empty or disabled properties are omitted from the JSON response.
