Vite + React SPA on a Grails 8 Backend
A Grails 8 rest_api JSON backend plus a Vite/React single-page app frontend in a single Gradle multi-project, packaged into one bootJar with the SPA served from the same origin as the API.
Authors: James Fredley
Grails Version: 8
1 Getting Started
In this guide you will build a two-tier application: an Apache Grails 8 rest_api backend that exposes JSON over /api/**, plus a Vite + React single-page app that consumes it. Both halves live in one Gradle multi-project build and ship as a single bootJar so the SPA serves from the same origin as the API in production - no CORS, no two-deployment dance.
This guide replaces the long-broken Webpack-era react and vue profiles with a modern Vite-based pipeline. The pattern works identically for Vue 3 (substitute @vitejs/plugin-vue for @vitejs/plugin-react and an equivalent App.vue).
This guide targets Apache Grails 8 / Spring Boot 4 / Vite 5 / Node 20+.
1.1 What You Will Build
By the end of the guide your repository will look like:
grails-vite-spa/
|-- settings.gradle # include ':backend' (frontend is npm-only)
|-- build.gradle # npmInstall, frontendBuild, copyFrontendToBackend
|-- backend/ # Grails 8 rest_api
| `-- grails-app/
| |-- domain/example/Book.groovy
| |-- controllers/example/BookController.groovy # /api/books
| |-- controllers/example/SpaController.groovy # forwards / -> SPA
| |-- controllers/example/UrlMappings.groovy
| `-- views/book/{_book,index,show}.gson
`-- frontend/ # Vite + React (NOT a Gradle subproject)
|-- package.json
|-- vite.config.js # proxy /api/** -> http://localhost:8080
|-- index.html
`-- src/{main.jsx, App.jsx, index.css}
Two run-modes:
-
Dev:
./gradlew :backend:bootRunin one shell,cd frontend && npm run devin another. Vite serves the SPA on:5173with HMR; the proxy forwards/api/**to the Grails app on:8080. -
Prod:
./gradlew :backend:bootJar. Gradle runsnpm run build, copiesfrontend/dist/intobackend/src/main/resources/public/, and packages everything into one bootJar. The SPA serves from/and/api/**from the same origin - CORS irrelevant.
The official Grails 8 reference manual (grails-doc/src/en/guide/REST/) covers the JSON-rendering side this guide consumes. The CORS chapter (grails-doc/src/en/guide/theWebLayer/cors.adoc) covers the grails.cors.enabled block this guide does NOT use - because the same-origin layout this guide builds removes the need for CORS entirely. Both chapters are worth knowing about.
Files touched in this chapter:
-
None
1.2 What You Will Need
To complete this guide you will need:
-
JDK 21
-
Node.js 20+ and a recent npm
-
About 45 minutes
1.3 How to Complete the Guide
Clone the finished sample and run it:
git clone -b grails8 https://github.com/grails-guides/grails-vite-spa.git
cd grails-vite-spa/complete
# Dev (two shells):
./gradlew :backend:bootRun # http://localhost:8080
cd frontend && npm install && npm run dev # http://localhost:5173
# Prod (one bootJar packages both halves):
./gradlew :backend:bootJar
java -jar backend/build/libs/backend-0.1.jar # http://localhost:8080
initial/ contains just the vanilla Grails 8 rest_api starter. complete/ is the assembled two-tier setup this guide builds.
2 Multi-Project Layout
A root settings.gradle includes only the backend; the frontend is a plain npm directory:
/*
* Two-tier multi-project build.
*
* backend/ Grails 8 rest_api - JSON over /api/**
* frontend/ Vite + React - NOT a Gradle subproject; driven by npm
* via the npmInstall + frontendBuild + copyFrontendToBackend
* tasks in the root build.gradle.
*
* A single bootJar produced by `./gradlew :backend:bootJar` ships both
* halves: the SPA's static bundle is copied into backend/src/main/
* resources/public/ before bootJar packages it.
*/
rootProject.name = 'grails-vite-spa'
include ':backend'
The two-shell-of-control choice is deliberate. Making frontend a Gradle subproject would force you to find or write a Vite Gradle plugin, deal with version drift, and introduce a layer that adds nothing the npm CLI does not already do. Letting Gradle drive npm via Exec tasks keeps each tool in its lane and lets you cd frontend && npm install exactly the way every JS dev expects.
The root build.gradle adds three tasks that bridge the two halves:
/*
* Root build script.
*
* The frontend module is NOT a Gradle subproject - it lives as a plain
* npm directory at frontend/ with its own package.json. Three Gradle
* tasks bridge the two halves:
*
* npmInstall -> npm install in frontend/
* frontendBuild -> npm run build (writes frontend/dist/)
* copyFrontendToBackend -> copies dist/ into backend/src/main/
* resources/public/ so bootJar packages it
*
* The backend's processResources task depends on copyFrontendToBackend
* so a plain `./gradlew :backend:bootJar` (or :backend:bootRun) builds
* the SPA and copies it into the right place automatically.
*/
def npxCmd = org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'npx.cmd' : 'npx'
def npmCmd = org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'npm.cmd' : 'npm'
// tag::frontendTasks[]
tasks.register('npmInstall', Exec) {
description = 'Install npm dependencies for the frontend SPA'
group = 'frontend'
workingDir = file('frontend')
inputs.file('frontend/package.json')
outputs.dir('frontend/node_modules')
commandLine npmCmd, 'install', '--no-fund', '--no-audit'
}
tasks.register('frontendBuild', Exec) {
description = 'Build the Vite/React SPA into frontend/dist/'
group = 'frontend'
dependsOn 'npmInstall'
workingDir = file('frontend')
inputs.dir('frontend/src')
inputs.file('frontend/index.html')
inputs.file('frontend/vite.config.js')
inputs.file('frontend/package.json')
outputs.dir('frontend/dist')
commandLine npmCmd, 'run', 'build'
}
tasks.register('copyFrontendToBackend', Copy) {
description = 'Copy the built SPA into backend/src/main/resources/public/'
group = 'frontend'
dependsOn 'frontendBuild'
from file('frontend/dist')
into file('backend/src/main/resources/public')
}
// end::frontendTasks[]
project(':backend') {
afterEvaluate {
tasks.named('processResources').configure { dependsOn rootProject.tasks.named('copyFrontendToBackend') }
}
}
Three patterns:
-
npmInstall→frontendBuild→copyFrontendToBackendis a chain. Each task declares itsinputs/outputsso Gradle’s up-to-date check skips the chain when nothing has changed. -
copyFrontendToBackendwrites intobackend/src/main/resources/public/. Spring Boot serves anything under that directory at/automatically; no further wiring needed. -
The
afterEvaluate { tasks.named('processResources').configure { dependsOn … } }line is what makes plain./gradlew :backend:bootJar(orbootRun) trigger the SPA build. Readers never type the frontend tasks directly.
Files touched in this chapter:
-
settings.gradle -
build.gradle
3 The Grails 8 JSON Backend
The backend is a vanilla Grails 8 rest_api starter (postgres, testcontainers, views-json features) with a small Book domain and a RestfulController:
package example
import grails.persistence.Entity
@Entity
class Book {
String title
String author
String isbn
static constraints = {
title blank: false, maxSize: 255
author blank: false, maxSize: 255
isbn blank: false, unique: true, matches: /^(97(8|9))?\d{9}(\d|X)$/
}
}
package example
import grails.rest.RestfulController
class BookController extends RestfulController<Book> {
static responseFormats = ['json']
BookController() { super(Book) }
@Override
protected List<Book> listAllResources(Map params) {
params.max = Math.min(params.int('max', 25), 100)
Book.list(params)
}
}
The JSON views shape the response:
import example.Book
model { Book book }
json {
id book.id
title book.title
author book.author
isbn book.isbn
}
import example.Book
model {
Iterable<Book> bookList
Long bookCount
}
json {
total bookCount
items bookList.collect { Book b -> g.render(template: 'book', model: [book: b]) }
}
This is exactly the pattern from the REST Library guide, narrowed to a single domain so the Vite-and-React story stays in focus. The RestfulController and .gson view conventions are documented in grails-doc/src/en/guide/REST/ (subchapters domainResources.adoc, restRendering.adoc).
Files touched in this chapter:
-
backend/grails-app/domain/example/Book.groovy -
backend/grails-app/controllers/example/BookController.groovy -
backend/grails-app/views/book/_book.gson -
backend/grails-app/views/book/index.gson
4 URL Mappings (/api/** vs SPA)
URL mappings split the application surface in two:
package example
class UrlMappings {
static mappings = {
// SPA routes resolved here so client-side routing survives
// a hard reload to any deep link.
'/' (controller: 'spa', action: 'index')
// JSON API surface consumed by the Vite/React frontend.
'/api/books'(resources: 'book')
'500'(view: '/error')
'404'(view: '/notFound')
}
}
-
/api/books(and any other future/api/**route) hits the JSON controllers. -
Everything else -
/,/books/42,/about- resolves toSpaController.index, which forwards to the bundledindex.html. The SPA’s client-side router takes over from there.
The forward (rather than a redirect) is what makes deep links survive a browser refresh: a hard reload on /books/42 returns the same index.html payload, the SPA boots, and React Router parses the URL.
The URL-mapping DSL itself is covered in grails-doc/src/en/guide/theWebLayer/urlMappings.adoc and the per-method-routing story in grails-doc/src/en/guide/theWebLayer/urlmappings/restfulMappings.adoc.
Files touched in this chapter:
-
backend/grails-app/controllers/example/UrlMappings.groovy
5 The SpaController Forwarder
The SpaController is one action that hands every non-/api/** request to the SPA’s bundled index.html:
package example
class SpaController {
static responseFormats = ['html']
/**
* Forwards any non-/api request to the SPA's bundled index.html
* (which Vite produced into src/main/resources/public/index.html).
* Spring Boot auto-serves that file at /; we forward here so client-
* side routes like /books/42 survive a browser refresh.
*/
def index() {
forward url: '/index.html'
}
}
forward url: '/index.html' re-dispatches inside the same servlet container; the response status, content type, and caching headers all come from the static-resource handler that serves index.html. No HTML duplication, no template engine in the loop.
|
Why not a wildcard URL mapping like |
The forward method available on every Grails controller is the same one the framework uses internally for URL-mapping rewrites; see grails-controllers/src/main/groovy/grails/web/util/WebUtils.java for the underlying implementation and grails-doc/src/en/guide/theWebLayer/controllers/forwarding.adoc for the user-facing reference.
Files touched in this chapter:
-
backend/grails-app/controllers/example/SpaController.groovy
6 The Vite/React Frontend
The frontend is a stock npm create vite@latest — --template react scaffold, simplified:
{
"name": "grails-vite-spa-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"vite": "^5.4.0"
}
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Grails + Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
import { useEffect, useState } from 'react'
/**
* Single-screen SPA: lists books from the Grails backend's /api/books
* and lets the user add a new one.
*
* fetch('/api/books') resolves through the Vite proxy in dev and
* directly (same-origin) from the bundled SPA in prod.
*/
export default function App() {
const [books, setBooks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('/api/books')
.then((r) => (r.ok ? r.json() : Promise.reject(r.statusText)))
.then((data) => setBooks(data.items || []))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
}, [])
return (
<main className="container">
<h1>Library</h1>
{loading && <p>Loading...</p>}
{error && <p className="error">Error: {error}</p>}
{!loading && !error && (
<ul>
{books.map((b) => (
<li key={b.id}>
<strong>{b.title}</strong> by {b.author} ({b.isbn})
</li>
))}
{books.length === 0 && <li>No books yet.</li>}
</ul>
)}
</main>
)
}
The single fetch('/api/books') call is the only contract between the two halves. The relative path means the same code runs in dev (where Vite proxies to :8080) and in prod (where the SPA is served from the bootJar at :8080 already).
To use Vue 3 instead of React, swap the dependency to vue + @vitejs/plugin-vue and replace App.jsx with an App.vue single-file component. The Gradle wiring, Vite proxy, and backend never change.
Files touched in this chapter:
-
frontend/package.json -
frontend/index.html -
frontend/src/main.jsx -
frontend/src/App.jsx
7 vite.config.js (Proxy + Build)
vite.config.js is the bridge between dev and prod:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
/**
* In dev: `npm run dev` starts Vite on :5173 with HMR.
* - Requests to /api/** get proxied to the Grails backend on :8080,
* so the SPA can fetch('/api/books') without dealing with CORS.
* In prod: `npm run build` writes a static bundle to ./dist/.
* - The root build.gradle copies dist/ into
* backend/src/main/resources/public/ so Spring Boot serves the
* SPA from the same origin as the API. Still no CORS.
*/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})
Two pieces are doing the work:
-
server.proxy['/api']is the dev-mode connector. Vite’s dev server runs on:5173; any request the SPA makes to/api/**is transparently forwarded to the Grails app on:8080. The SPA never knows there are two ports -fetch('/api/books')Just Works.changeOrigin: truerewrites theHostheader so the Grails side sees a same-origin request. -
build.outDir = 'dist'controls wherenpm run buildwrites the static bundle. Our rootbuild.gradle’s `copyFrontendToBackendtask reads from this exact path.
emptyOutDir: true makes a clean rebuild every time, so a renamed asset does not leave a stale predecessor in dist/.
Files touched in this chapter:
-
frontend/vite.config.js
8 Dev Mode (Two Servers, One Origin)
Dev mode runs both servers in parallel and lets each one own what it is good at:
# Shell 1
./gradlew :backend:bootRun
# Backend on http://localhost:8080
# Spring DevTools reloads on Groovy changes
# Shell 2
cd frontend
npm install
npm run dev
# Vite on http://localhost:5173
# HMR updates the browser on JSX/CSS changes without losing component state
The browser opens http://localhost:5173. Every fetch('/api/books') call from the SPA hits Vite first; Vite sees the /api prefix in the proxy config and forwards to http://localhost:8080/api/books. The response comes back the way it came.
Two things to keep an eye on:
-
Same-origin in dev too. Because the SPA always calls relative paths and Vite proxies them, the browser never sees a cross-origin request. CORS preflight requests do not happen even in dev.
-
Spring DevTools for the backend, Vite HMR for the frontend. Each side reloads independently; you can edit
App.jsxwithout restarting Grails, and edit a Groovy controller without losing your SPA state. The Spring DevTools story for Grails 8 is covered ingrails-doc/src/en/guide/gettingStarted/developmentReloading.adoc.
Files touched in this chapter:
-
None directly (this chapter is a runbook)
9 Prod Build (One bootJar, One Origin)
Production packaging is one Gradle command:
./gradlew :backend:bootJar
Behind the scenes, that command:
-
:npmInstallrunsnpm installinfrontend/(skipped ifnode_modules/is up-to-date). -
:frontendBuildrunsnpm run build, which isvite build, which producesfrontend/dist/index.htmlplus a hashed JS+CSS bundle infrontend/dist/assets/. -
:copyFrontendToBackendcopiesfrontend/dist/*intobackend/src/main/resources/public/. -
:backend:processResources(which:backend:bootJardepends on) picks up the newpublic/tree and includes it in the bootJar. -
:backend:bootJarwritesbackend/build/libs/backend-0.1.jar.
The resulting jar serves the SPA from / and the API from /api/**, both from the same origin. CORS is a non-issue. The deployment artifact is one file.
|
The matching Docker bootBuildImage guide turns this exact bootJar into an OCI image. Combined, the two guides give you |
Files touched in this chapter:
-
None directly (this chapter is a runbook for the build chain wired up in
layout.adoc)
10 Why CORS Never Appears
CORS does not appear in either dev or prod for this layout - and that is the point.
-
In dev, the browser only ever talks to Vite on
:5173. Vite forwards/api/**to Grails on:8080via its server-to-server proxy; the browser never sees a cross-origin request. No CORS preflight, noAccess-Control-Allow-Originheaders. -
In prod, the browser only ever talks to the bootJar on
:8080. The SPA’s static bundle is served from/; the API is served from/api/**. Both are the same origin. Same story: no CORS.
For the case where the SPA and the API genuinely live on different origins (e.g. SPA on app.example.com, API on api.example.com), Grails ships a first-class CORS configuration block. From grails-doc/src/en/guide/theWebLayer/cors.adoc (the official 8.0.x reference):
grails:
cors:
enabled: true
allowedOrigins:
- 'https://app.example.com'
allowedMethods:
- GET
- POST
allowCredentials: true
The grails.cors block produces a Spring CorsConfiguration under the hood, mapped to /** by default. Per-mapping overrides are documented in the same cors.adoc chapter under the mappings: sub-key. The Spring Boot CORS documentation is the underlying reference for the option semantics (max-age, exposed headers, etc.).
|
Auth: HTTP-only cookie sessions work transparently with this same-origin layout - the browser sends the session cookie on every request without any client-side wiring. JWT in |
Files touched in this chapter:
-
None (CORS only becomes relevant if you split the SPA and the API across origins)
11 Do you need help with Grails?
Help with Apache Grails
Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.