No-Fuss React and JSX with Spring Boot

In almost every book, article, or general instructions for starting a new React application you'll find a dizzying array of recommendations for full-blown front-end development pipelines. One of the most common seems to be Create React App (“the last choice for a real-world production application” according to one blog), although you'll also see Vite, Next.js, Remix, Gatsby, and Webpack mentioned. But what if you already have a build workflow, such as a Maven/Gradle project with Spring Boot, and you just want to add React to some pages? You don't have to install a monstrous parallel build system; you can integrate React transparently into your build system with a little configuration.

This tutorial covers using React with Spring Boot (e.g. Spring MVC, perhaps along with Thymeleaf), but the technique here apply to any Maven or Gradle build workflow. The principles explained can be applied to any project that uses React. Even if you do decide to go with one of those all-encompassing front-end build systems, this explanation will help you understand what's going on under the hood. After following these instructions, you'll be able to kick off a complete Maven build using mvn clean package as you normally would, but now with JSX transpiled to JavaScript in the correct location, ready for installation and/or deployment. You'll also learn how to live JSX transpiling without restarting while running a Spring Boot application in the IDE.

React is JavaScript

React is just JavaScript. As you might imagine, there is a way to include React on your page just by referencing the JavaScript files, and it doesn't differ greatly from how it was done 20 years ago. You can find the latest JavaScript files on UNPKG, both in development and production flavors. The biggest difference is that the development version being minified. The rest of this discussion assumes you're using the production flavor.

There are two files you need: one for React, and one for ReactDOM. The following links request the latest from the v18 line of React, redirecting to the URL for the latest full version number. The UNPKG site explains how to request a more specific version in the URL if you ever need to.

development
production

You can download these using curl if you like. Making sure you use the --location or -L option to follow redirects. The -O option uses the default filename; here I've used both together as -LO.

curl -LO https://unpkg.com/react@18/umd/react.production.min.js
curl -LO https://unpkg.com/react-dom@18/umd/react-dom.production.min.js

Now place those JavaScript files in your project and include them in your web page as you normally would:

<script src="assets/js/react.production.min.js"></script>
<script src="assets/js/react-dom.production.min.js"></script>

If you're using ThymeLeaf, with templates in src/main/resources/templates and static files in src/main/resources/static, you might places the JavaScript files in src/main/resources/static/assets/js and include the files from your src/main/resources/templates/home.html page like the following. Note the difference in paths between the th:src and src attributes! The idea here is that you can load the templates in a browser directly from the directory, with no server, and the links to the static files (located in a separate directory) will still work. Thymleaf will update the links to their final location without the static/templates distinction when compiling the templates at runtime. If you have doubts, check the Thymeleaf Link URLs documentation or the Thymeleaf Standard URL Syntax article.

<script th:src="@{assets/js/react.production.min.js}" src="../static/assets/js/react.production.min.js"></script>
<script th:src="@{assets/js/react-dom.production.min.js}" src="../static/assets/js/react-dom.production.min.js"></script>

I'll only show the Thymeleaf example here this once for loading the static JavaScript files. For the rest of this discussion, I'll only use normal HTML script src references, because as you'll learn the JSX files will need to be compiled anyway and will not be available for viewing the original HTML file in a browser without running a server. In the code below, remember to use the correct src for to link to the React JavaScript files based upon whether you are using Thymeleaf.

You can test this with the quick script below:

<script type="module">
  // clear whatever is in the document already (if needed)
  document.body.innerHTML = '<div id="app"></div>';

  // replace the entire document body with a generated React element
  const root = ReactDOM.createRoot(document.getElementById('app'));
  root.render(React.createElement("h1", null, "Hello world!"));
</script>

This is similar to the test code given at Add React to an Existing Project, but there are a few subtle (and important!) differences:

And there you go. A quick and simple way to include React on any page.

JSX

“But hold on, I want to use JSX.” Of course you do, and the other members on your team will too. Obviously the browser doesn't understand JSX—that's where transpiling comes in. Transpiling converts JSX to normal JavaScript. You'll want transpiling to take place before it gets to the browser, so that the browser is obvlivious that the JSX even existed.

The primary tool to transpile JSX is called Babel. Many larger front-end build tools such as Webpack and Gatsby still use Babel under the hood. Here you'll skip the cruft and deal directly with Babel in a simple and straightforward way. (As mentioned earlier Babel supports transpiling your JavaScript code as well, but we won't use that functionality.)

The rest of this discussion assumes that you're using Maven and Spring Boot with Thymeleaf with a standard directory layout. If you're not using Thymeleaf, you can adjust the paths so that even the HTML files are in src/main/resources/static, or further adjust the paths to your liking.

src/main/resources/static
Source files for static web files to be served.
src/main/resources/templates
Source files for Thymeleaf page templates to be dynamically compiled.
target/classes/static
Where the build process copies static web files to be served.
target/classes/templates
Where the build process copies Thymeleaf page templates to be served.

We'll consider the scenario in which you have a MyComponent React component stored in src/main/resources/static/MyComponent.jsx. Initially the component will look like this:

const MyComponent = () =>
  <h1>This is my component.</h1>

export default MyComponent;

Manually Transpiling JSX

Install Babel

You'll first need Babel. You can install it with NPM, which was installed when you installed Node.js. Run the following command from the root of your project. (If your web files are in a Maven subproject such as web-service, run Babel from the root of that subproject.) Don't worry, it won't won't mess up your development configuration. It will just add a subdirectory and a couple of files, all explained below.

npm install --save-dev @babel/core @babel/cli @babel/preset-react

This installs the Babel core compiler, the Babel CLI for running it manually, and the React Preset for transpiling React JSX. (I've purposely left out @babel/preset-env because as mentioned we won't be transpiling the JavaScript itself.)

NPM installs Babel (as is typically does for packages) in the node_modules directory, relative to the directory you were in when you ran the command. It adds two files to manage the Node.js modules you're using and lock down their versions. You'll want to check these .json files into version control, but not the node_modules directory.

You should add the node_modules directory to .gitignore so that it won't accidentally get committed in the repository.

# Node.js
/node_modules/

The use of --save-dev indicates that Babel should be marked as a developer tool in package.json. It's the appropriate option to use here, although for our purposes it doesn't make much difference.

Run Babel

Now you can manually transpile your JSX files, including the example MyComponent.jsx (mentioned above, but described in detail later on), using NPX, another tool installed with Node.js. If you are using the Windows command prompt, you'll want to use backslashes in the paths. The command should work in PowerShell as-is.

npx babel src/main/resources/static --out-dir target/classes/static --presets=@babel/preset-react --only="**/*.jsx"

This use of --presets tells Babel to transpile JSX code, and this use of --only tells it to only tranpile .jsx files. (As I keep mentioning, here I'm not addressing JavaScript transpiling; integrating that into our workflow will only bring more complications.)

Configure Babel

You can make your life easier, as well as facilitate the integration with Maven (below), by adding the following .babelrc.json configuration file to the root of the project, in the same directory as package.json. (I'm using the .babelrc.json form of a Babel configuration file in case you want to have different settings across a multi-module Maven project.) Make sure to place .babelrc.json under version control as well:

{
  "presets": [
    "@babel/preset-react"
  ],
  "only": [
    "**/*.jsx"
  ]
}

Now you can simply invoke the following form and Babel will already know which settings you want.

npx babel src/main/resources/static --out-dir target/classes/static

If you clean and build your project, you'll want to manually invoke this command before running your Spring Boot application so that the correct .js files will be generated. (The target directory tree will still contain the original .jsx files; we'll deal with that below.)

Let's create a Windows batch file to make this easier, naming it transpile-target-jsx.cmd. Note the use of %* to pass additional arguments.

@ECHO OFF
REM transpile-target-jsx.cmd
npx babel src\main\resources\static --out-dir target\classes\static %*

With Bash it would look something like this for transpile-target-jsx.sh. Note the use of %@ to pass additional arguments.

#!/bin/bash
# transpile-target-jsx.sh
npx babel src/main/resources/static --out-dir target/classes/static $@

Automatically Run Babel When JSX Files Change

If you want to have Babel continually watch the src tree for JSX changes and transpile them to the target tree, just pass the --watch option to the script. It will run until you stop it using Ctrl+C. On PowerShell you can have this run in a separate window, minimized by default. With Bash you can use & to make the command run in the background. Further details for running a task in the background in Bash are outside the scope of this discussion. The -WorkingDirectory . is optional, but you'll need to change it if your web service is in a Maven subproject—but I'm getting ahead of myself.

Start-Process -WorkingDirectory . -FilePath transpile-target-jsx.cmd -ArgumentList "--watch" -WindowStyle Minimized

Bootstrapping JSX

We can now revisit the first script from this tutoral, taken from Add React to an Existing Project. Instead of placing the code in an internal <script>, place the following revised version in a separate App.jsx file in the src/main/resources/static folder, alongside your HTML file:

// clear whatever is in the document already (if needed)
document.body.innerHTML = '<div id="app"></div>';

// render a generated React element instead
const root = ReactDOM.createRoot(document.getElementById('app'));
root.render(<h1>Hello, world</h1>);

Notice that now the only differences between our code and the code at Add React to an Existing Project is that we're missing the initial import { createRoot } from 'react-dom/client';, and that we prefix the API call with ReactDOM. As mentioned above, React itself doesn't support ES6 modules. (There may be a way to wrap React to convert it to the ES module system on the fly, but I'll leave that for a separate post.)

Here it means that we'll still need to link to the React library in the main HTML file. It also means that we'll need to link in the App module from the HTML file, and importantly, we must reference App.js with a .js extension instead of a .jsx extension because it contains JSX syntax and will be transpiled to .js as we set up above. Here as well don't forget the type="module". Here is what the HTML file looks like now:

<script src="assets/js/react.production.min.js"></script>
<script src="assets/js/react-dom.production.min.js"></script>
…
<script src="App.js" type="module"></script> 

Internal scripts are no longer needed on the HTML page, and all the JavaScript inside .jsx files can use ES6 modules and JSX to their hearts' content. (Referring to React APIs will still require a prefix of React. or ReactDOM., but there may not be more than a single React API call in most applications.)

So let's create a MyComponent React component stored in src/main/resources/static/MyComponent.jsx. We'll use it as the main application component. Starting out it will look like this:

const MyComponent = () =>
  <h1>This is my component.</h1>

export default MyComponent;

Update App.js to use MyComponent. Here you can use ES6 module imports, because your code, unlike the React code, supports them. Don't forget to import the .js transpiled version of the component, not the .jsx version.

import MyComponent from './MyComponent.js';

// clear whatever is in the document already (if needed)
document.body.innerHTML = '<div id="app"></div>';

// render your React component instead
const root = ReactDOM.createRoot(document.getElementById('app'));
root.render(<MyComponent />);

Now you're completely set and ready for full React development with JSX!

JSX Embedding JSX

As one example of the type of normal React JSX development you can do, you can embed one component in another by creating a new component named src/main/resources/static/ChildComponent.jsx.

const ChildComponent = () =>
  <span>This component is embedded in another one.</span>

export default ChildComponent;

Update src/main/resources/static/MyComponent.jsx to look like this:

import ChildComponent from './ChildComponent.js';

const MyComponent = () =>
  <h1>This is my component. <ChildComponent /></h1>

export default MyComponent;

With these changes you can perform normal React, JSX, and ES6+ development from here on out! Now you'll want to integrate JSX transpilation into your Maven build, as explained below.

Bootstrapping JSX within HTML (optional)

If instead you feel you must bootstrap React in a <script> element in the main HTML file, without using a separate App.jsx file, you'll need to use the React API to create the root component. It would look something like this. Remember to include transpiled component file.

<script src="assets/js/react.production.min.js"></script>
<script src="assets/js/react-dom.production.min.js"></script>
…
<script type="module">
  import MyComponent from './MyComponent.js'
  …
  // clear whatever is in the document already (if needed)
  document.body.innerHTML = '<div id="app"></div>';

  // render your React component instead
  const root = ReactDOM.createRoot(document.getElementById('app'));
  root.render(React.createElement(MyComponent, null));
</script>

Integrating Babel into a Maven Build

If you're familiar with Maven, you know that one should be able to perform the full build merely by executing mvn clean package or mvn clean install. We don't want to have to manually run some batch or script file after the Maven build process in order to transpile JSX into .js files in the target tree. Furthermore we don't want the .jsx files that Maven will copy to the target tree in the first place.

We can take care of these problems using the marvelous Frontend Maven Plugin, along with some tweaks to the Maven POM. The Frontend Maven Plugin knows how to download a local copy of Node.js as needed, and it integrates into the Maven build life-cycle. It is intended to work on Windows, OS X, or Linux, so it should fit into your remote CD/CI pipeline. (The instructions here were tested on Windows 10.)

Skip Copying JSX Resources

Before anything else, tell Maven not to copy over the .jsx files. We'll soon use Babel to transpile them as we did above, except we'll do it within the Maven build itself. Update the Maven POM pom.xml file like this:

…
  <build>
    …
    <resources>
      <resource>
        <directory>${project.basedir}/src/main/resources</directory>
        <excludes>
          <exclude>**/*.jsx</exclude>
        </excludes>
      </resource>
    </resources>

Define Frontend Maven Plugin

Set up the Node.js version to use in the POM.

…
  <properties>
    …
    <node.version>18.15.0</node.version>

Then configure the Frontend Maven Plugin version:

…
  <build>
    …
    <pluginManagement>
      <plugins>
        <plugin>
          <groupId>com.github.eirslett</groupId>
          <artifactId>frontend-maven-plugin</artifactId>
          <version>1.12.1</version>
        </plugin>

        <!-- (optional) Tell Eclipse m2e not to worry
        that it doesn't recognize the `npx` goal. -->
        <plugin>
          <groupId>org.eclipse.m2e</groupId>
          <artifactId>lifecycle-mapping</artifactId>
          <version>1.0.0</version>
          <configuration>
            <lifecycleMappingMetadata>
              <pluginExecutions>
                <pluginExecution>
                  <pluginExecutionFilter>
                    <groupId>com.github.eirslett</groupId>
                    <artifactId>frontend-maven-plugin</artifactId>
                    <versionRange>[1.12.1,)</versionRange>
                    <goals>
                      <goal>npx</goal>
                    </goals>
                  </pluginExecutionFilter>
                  <action>
                    <ignore></ignore>
                  </action>
                </pluginExecution>
              </pluginExecutions>
            </lifecycleMappingMetadata>
          </configuration>
        </plugin>
      </plugins>

The lifecycle mapping is optional and just prevents a warning in Eclipse that the IDE doesn't recognize one of the plugin's goals. This doesn't affect the Maven build itself; a complete Maven build will still work fine.

Download Node.js If Needed

In the actual plugin declaration, you'll want to set up several Maven executions, each of which perform some action (referred to as a Maven goal) each in the correct a phase of the project.

First configure the Frontend Maven Plugin to download and install a local copy of the Node.js executables as necessary. This local copy will be completely indpendent of the version you've installed on your system. I've tied this install-node-and-npm goal to the initialize phase so that it gets down early in the build, although the default setting is the generate-resources phase. Whatever you decide, remember that downloading and installing Node.js is only done once for the project; on subsequent builds the plugin will find Node.js and not download it again. In addition the plugin caches the downloaded files in the ~/.m2 directory so they won't need to be downloaded for other projects. Note the format of the <nodeVersion> value, requiring a v prefix; the default download URL requires this form in order to find the correct version. See Issue #1075 for background details.

…
  <build>
    …
      <plugin>
        <groupId>com.github.eirslett</groupId>
        <artifactId>frontend-maven-plugin</artifactId>
        <executions>
          <execution>
            <id>install-node</id>
            <goals>
              <goal>install-node-and-npm</goal>
            </goals>
            <phase>initialize</phase>
            <configuration>
              <nodeVersion>v${node.version}</nodeVersion>
            </configuration>
          </execution>
          …
        </executions>
        <configuration>
          <installDirectory>.node</installDirectory>
        </configuration>
      </plugin>

By default the plugin will store its local Node.js and NPM exectubles and related files in a local node directory. I prefer the that the directory be named .node, so I've added the <installDirectory> configuration you see above. Whatever the case you'll want to keep them out of the source code repository, so update your .gitignore file. If you have a multi-module Maven project, these particular lines should go in the .gitignore for the appropriate subproject, as they use absolute paths, or update the paths to match liberally.

# Node.js
/node_modules/
/.node/

Install Babel

Tell the plugin that after installing Node.js and NPM, you want to install Babel. You must have created the appropriate package.json file above in order to tell the plugin which NPM packages, in this case Babel Core and Babel CLI, to install into its local installation. Make sure you place this install-node-packages execution after the install-node above.

…
  <build>
    …
      <plugin>
        <groupId>com.github.eirslett</groupId>
        <artifactId>frontend-maven-plugin</artifactId>
        <executions>
          …
          <execution>
            <id>install-node-packages</id>
            <goals>
              <goal>npm</goal>
            </goals>
            <phase>initialize</phase>
          </execution>
          …
        </executions>
        <configuration>
          <installDirectory>.node</installDirectory>
        </configuration>
      </plugin>

Transpile JSX

Finally tell the plugin to perform the actual transpiling using NPX, indicating the appropriate src and target trees. This takes care of the .jsx files we skipped above in the <resources> section: it ensures that they will be transpiled into .js files in the target tree. I've specified that this should take place in the process-resources phase, the same phase that the Maven Resources Plugin resources goal will copy the other resources from the <resources> section definition.

…
  <build>
    …
      <plugin>
        <groupId>com.github.eirslett</groupId>
        <artifactId>frontend-maven-plugin</artifactId>
        <executions>
          …
          <execution>
            <id>transpile-jsx</id>
            <goals>
              <goal>npx</goal>
            </goals>
            <phase>process-resources</phase>
            <configuration>
              <arguments>babel ${project.basedir}/src/main/resources/static --out-dir ${project.build.outputDirectory}/static</arguments>
            </configuration>
          </execution>
        </executions>
        <configuration>
          <installDirectory>.node</installDirectory>
        </configuration>
      </plugin>

Now you can run mvn clean package as you normally would. No JSX files will be included in the final packaging; instead, they will all have been transpiled to .js files.

Integrating Babel into Your IDE

As icing on the cake, you'll probably want to run your Spring Boot application in your IDE, and have any edits to JSX files appear live in your browser after a page reload. You should be able to achieve this with only three steps:

  1. Turn on auto-build in your IDE. (e.g. Project > Build Automatically from the Eclipse main menu.) This will ensure that edits to general resources in the src subtree will be copied to the target subtree.
  2. Run Babel CLI in --watch mode. (This was explained above.) This will ensure that if there are any changes to JSX files in the src/main/resources/static subtree, Babel will immediately transpile them to the target/classes/static subtree.
  3. Install Spring Devtools. (This merely involves adding another dependency to the POM.) This will ensure that Tomcat will update the files it is serving as soon as you edit them or Babel transpiles them to the target subtree.

That's it. Now you can develop React with JSX and have live updates during development just like everyone using other front-end pipelines. No Create React App. No Webpack. No huge front-end pipeline running parallel with your Spring Boot workflow. Just a normal Maven build. You can invoke mvn clean package exactly the same as you did before you added React to the project. The only difference from a normal Maven Spring Boot development setup is that, for live re-transpilation during development, you'll need the added step of running Babel CLI in --watch mode in a separate terminal window when running the application in the IDE.