Show Navigation

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:bootRun in one shell, cd frontend && npm run dev in another. Vite serves the SPA on :5173 with HMR; the proxy forwards /api/** to the Grails app on :8080.

  • Prod: ./gradlew :backend:bootJar. Gradle runs npm run build, copies frontend/dist/ into backend/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:

settings.gradle
/*
 * 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:

build.gradle
/*
 * 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:

  • npmInstallfrontendBuildcopyFrontendToBackend is a chain. Each task declares its inputs/outputs so Gradle’s up-to-date check skips the chain when nothing has changed.

  • copyFrontendToBackend writes into backend/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 (or bootRun) 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:

backend/grails-app/domain/example/Book.groovy
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)$/
    }
}
backend/grails-app/controllers/example/BookController.groovy
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:

backend/grails-app/views/book/_book.gson
import example.Book

model { Book book }

json {
    id     book.id
    title  book.title
    author book.author
    isbn   book.isbn
}
backend/grails-app/views/book/index.gson
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:

backend/grails-app/controllers/example/UrlMappings.groovy
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 to SpaController.index, which forwards to the bundled index.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:

backend/grails-app/controllers/example/SpaController.groovy
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 '/$path'(controller: 'spa', action: 'index')? Because URL mappings resolve before static resources. A wildcard mapping would intercept /favicon.ico and /assets/main-abc123.js, returning the SPA’s index.html instead of the asset. Letting Spring Boot’s static-resource handler claim everything except /api/ and / is the simplest correct shape.

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:

frontend/package.json
{
  "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"
  }
}
frontend/index.html
<!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>
frontend/src/main.jsx
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>,
)
frontend/src/App.jsx
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:

frontend/vite.config.js
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: true rewrites the Host header so the Grails side sees a same-origin request.

  • build.outDir = 'dist' controls where npm run build writes the static bundle. Our root build.gradle’s `copyFrontendToBackend task 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.jsx without restarting Grails, and edit a Groovy controller without losing your SPA state. The Spring DevTools story for Grails 8 is covered in grails-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:

  1. :npmInstall runs npm install in frontend/ (skipped if node_modules/ is up-to-date).

  2. :frontendBuild runs npm run build, which is vite build, which produces frontend/dist/index.html plus a hashed JS+CSS bundle in frontend/dist/assets/.

  3. :copyFrontendToBackend copies frontend/dist/* into backend/src/main/resources/public/.

  4. :backend:processResources (which :backend:bootJar depends on) picks up the new public/ tree and includes it in the bootJar.

  5. :backend:bootJar writes backend/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 git push → tagged GHCR image → single-container deploy.

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 :8080 via its server-to-server proxy; the browser never sees a cross-origin request. No CORS preflight, no Access-Control-Allow-Origin headers.

  • 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-app/conf/application.yml
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 Authorization headers is the alternative when the SPA is hosted independently of the API; it requires the SPA to attach the header explicitly on every fetch. The cookie-session path is recommended for browser-only SPAs unless you have a reason to pick JWT.

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.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.