Skip to content

About

Grails plugin for using Inertia.js

Resources

Stars

13 stars

Watchers

3 watching

Forks

Repository files navigation

🧩 Grails Adapter for Inertia.js

Maven Central Java CI

Grails plugin for using Inertia.js to build single-page apps without building an API.

❔ What is Inertia.js?

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.

🎬 Demo application

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.

Screenshot of the Ping CRM application

📦 Plugin Installation

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 install

📖 Usage

In 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 bootJar

Vite dev server

In 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 value

To 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: true

Favicon

The 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.svg

A 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.

Title

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.

Language

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: en

The 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.

Routes

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.

Context path

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
  // ...
}))

⚙️ SSR

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 build

Then 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 milliseconds

The @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.

Upgrading to 4.0

  • 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 the data-page attribute of the app element. Upgrade the @inertiajs packages to version 3 (see Inertia 3 support).
  • Outside the development environment, the page now loads the built assets by default, including in test and in custom environments. Before, it loaded the JavaScript from the Vite dev server at http://localhost:3000 in every environment except production. The Vite manifest was already required outside development, so tests that render the page need the built assets, for example by making processResources depend on the Vite build, or they can keep using the dev server by setting inertia.vite.devServer.enabled: true for test (see Vite dev server).
  • The page now has a title until a page component sets its own: the application name, or inertia.title when it is configured (see Title). Before, the page had no title.
  • The lang attribute of the page is now the locale resolved for the request, instead of always en. 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.gsp now gets the built assets of the entry point as inertiaViteEntry, with its file, css and preloads, instead of the whole Vite manifest as inertiaManifest. The entry is resolved from the manifest when the application starts, so a manifest without the entry fails the startup.

Inertia 3 support

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 controls

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.

About

Grails plugin for using Inertia.js

Resources

Stars

13 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages