Maven Packages Spring Boot and React into the Same Application

  1. Background
  2. Creating a Spring Boot Project
  3. Creating a React Project Module
  4. Debugging Front-end and Back-end
  5. Packaging React into Spring Boot
  6. Automating React Compilation Before Maven Packaging
  7. Testing the WAR File
  8. Fixing the 404 Error After Refreshing the Page in React Router

Background

In medium to large enterprises, it is common not to use the built-in Tomcat or other servers in Spring Boot. Instead, the application is packaged into a WAR file and deployed to enterprise-level web containers (JBoss, WebLogic, WebSphere, Liberty, etc.) because the built-in Tomcat container has weak performance or does not meet enterprise standards. When packaging, we can place the front-end files generated by npm build into the static folder of Spring Boot, and finally package them into a single WAR file.

This article has the following goals:

  1. Create a Spring Boot and React project in IntelliJ IDEA.
  2. Debug the front-end and back-end, configure Proxy to solve cross-origin issues.
  3. Package Spring Boot and React into a WAR file.
  4. Fix the 404 error after refreshing the page.

Creating a Spring Boot Project

After creation, we notice two differences compared to applications with built-in Tomcat:

  1. In pom.xml, the output file format is WAR instead of JAR, and a Tomcat dependency is added with the scope set to provided. This means the dependency is referenced during the compilation phase but not packaged into the WAR file, as the external web container where the WAR file will be deployed will provide this dependency. Using the Tomcat dependency does not mean the generated WAR file can only be deployed in Tomcat; during development, we only use the Servlet API included in this dependency.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <packaging>war</packaging>

    ...

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-tomcat</artifactId>
    <scope>provided</scope>
    </dependency>
  2. A new ServletInitializer.java file is added. ServletInitializer is the entry point for the web container to start Spring Boot from the WAR file, while PokemonApplication is the entry point for starting Spring Boot (and thus the built-in Tomcat) from a JAR file. In the WAR file, Spring Boot is not started from PokemonApplication, so it can be deleted.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    package org.chris.pokemon;

    import org.springframework.boot.builder.SpringApplicationBuilder;
    import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;

    public class ServletInitializer extends SpringBootServletInitializer
    {
    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application)
    {
    return application.sources(PokemonApplication.class);
    }
    }

Usually, IntelliJ IDEA has already created an artifact of type web exploded. If not, create one manually.

During development, choose a lightweight Tomcat for faster startup. In Edit Configurations..., create a Tomcat Server and select Local. Add the artifact to the Deployment tab.

Note: Change the /pokemon_war_exploded in the Application Context to /. Otherwise, your root path will be http://localhost:8080/pokemon_war_exploded instead of http://localhost:8080/.

If multiple applications are running in the same Tomcat, different contexts can be used to distinguish them. However, since we only have one, we use /.

In the Server panel, set both On 'Update' Action and On frame deactivation to Update classes and resources. This allows automatic updates after modifying the code without restarting Tomcat. Note: Hot code updates only succeed when modifying the internal logic of methods. If the class structure is modified (e.g., adding, deleting, or modifying classes, fields, methods, or changing method parameters), hot updates will fail, and Tomcat must be restarted. In After launch, you can set your preferred browser to open automatically after Tomcat starts successfully.

Start the server. If you see Spring outputting information in the console, the startup is successful.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/

:: Spring Boot :: (v3.3.4)

2024-10-13T10:38:33.587+08:00 INFO 9147 --- [pokemon] [on(2)-127.0.0.1] org.chris.pokemon.ServletInitializer : Starting ServletInitializer v0.0.1-SNAPSHOT using Java 23 with PID 9147 (/Users/chris/IdeaProjects/pokemon/target/pokemon-0.0.1-SNAPSHOT/WEB-INF/classes started by chris in /Users/chris/Work/IdeaProjects/libs/apache-tomcat-10.0.12/bin)
2024-10-13T10:38:33.590+08:00 INFO 9147 --- [pokemon] [on(2)-127.0.0.1] org.chris.pokemon.ServletInitializer : No active profile set, falling back to 1 default profile: "default"
2024-10-13T10:38:34.504+08:00 INFO 9147 --- [pokemon] [on(2)-127.0.0.1] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 856 ms
2024-10-13T10:38:35.191+08:00 INFO 9147 --- [pokemon] [on(2)-127.0.0.1] org.chris.pokemon.ServletInitializer : Started ServletInitializer in 2.063 seconds (process running for 4.649)
[2024-10-13 10:38:35,232] Artifact pokemon:war exploded: Artifact is deployed successfully
[2024-10-13 10:38:35,233] Artifact pokemon:war exploded: Deploy took 3,575 milliseconds
2024-10-13T10:38:35.751+08:00 INFO 9147 --- [pokemon] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2024-10-13T10:38:35.752+08:00 INFO 9147 --- [pokemon] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms

If you only see the following information, it means the Tomcat artifact deployment was successful, but Spring Boot did not start.

1
2
[2024-10-13 10:40:45,583] Artifact pokemon:war exploded: Artifact is deployed successfully
[2024-10-13 10:40:45,583] Artifact pokemon:war exploded: Deploy took 1,221 milliseconds

In the Tomcat Catalina Log tab, you will see the following warning:

1
13-Oct-2024 10:45:44.580 WARNING [RMI TCP Connection(2)-127.0.0.1] org.apache.tomcat.util.descriptor.web.WebXml.setVersion Unknown version string [5.0]. Default version will be used.

This error occurs because lower versions of Tomcat do not support higher versions of Spring Boot. I encountered this issue when deploying Spring Boot 3.3.4 with Tomcat 8.5.76. Changing to Tomcat 10.0.12 allowed Spring Boot to start successfully.

Creating a React Project Module

In Project Structure, under Module, click New Module, select a React project, and name it web.

After clicking OK, wait for a while (usually very slow). You can go grab a coffee.

Once the React project is created, a web folder will be created. In Edit Configurations..., add npm, select the start command, and click OK to start.

If everything goes well, you will see the following information in the console, and the browser will automatically open http://localhost:3000.

1
2
3
4
5
6
7
8
9
10
11
Compiled successfully!

You can now view web in the browser.

Local: http://localhost:3000
On Your Network: http://192.168.31.194:3000

Note that the development build is not optimized.
To create a production build, use npm run build.

webpack compiled successfully

Debugging Front-end and Back-end

Create a domain package and create a Pokemon class under it.

1
2
3
4
5
6
7
8
9
10
11
12
package org.chris.pokemon.domain;

import lombok.Builder;
import lombok.Data;

@Data
@AllArgsConstructor
public class Pokemon
{
private String name;
private String type;
}

Create a controller package and create a PokemonController class under it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package org.chris.pokemon.controller;

import org.chris.pokemon.domain.Pokemon;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.List;

@RestController
@RequestMapping("/api/pokemon")
public class PokemonController
{
@GetMapping("/list")
public ResponseEntity<?> list()
{
List<Pokemon> pokemonList = new ArrayList<>();
pokemonList.add(new Pokemon("Charmander", "Fire"));
pokemonList.add(new Pokemon("Squirtle", "Water"));

return ResponseEntity.ok(pokemonList);
}
}

After starting, open http://localhost:8080/api/pokemon/list to get the following response:

1
2
3
4
5
6
7
8
9
10
[
{
"name": "Charmander",
"type": "Fire"
},
{
"name": "Squirtle",
"type": "Water"
}
]

Modify web/src/App.js to the following code to attempt to fetch data from the back-end and display it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import './App.css';
import {useEffect, useState} from "react";

function App() {
const [pokemonList, setPokemonList] = useState([]);
useEffect(() => {
fetch('http://localhost:8080/api/pokemon/list')
.then(response => response.json())
.then(data => setPokemonList(data));
}, []);

return (
<div>
<div>Pokemon List:</div>
<ol>
{pokemonList.map(pokemon => <li key={pokemon.name}>{pokemon.name} ({pokemon.type})</li>)}
</ol>
</div>
);
}

export default App;

Refresh the React page, but unfortunately, it fails. Open the browser’s developer tools and see an error message in the Console.

In the Network tab, you can see a CORS error when fetching data:

What’s going on? The browser’s security policy does not allow cross-origin requests. http://localhost:3000 and http://localhost:8080 are considered different origins. There are many solutions to cross-origin issues, but the simplest way in this project is to forward the request to http://localhost:8080. This way, we only need to access http://localhost:3000.

Create a setupProxy.js file in web/src/. The code below forwards all requests starting with /api to http://localhost:8080.

1
2
3
4
5
6
7
8
9
10
const {createProxyMiddleware} = require('http-proxy-middleware')
module.exports = function (app) {
app.use(
'/api', // Specify the requests to be forwarded
createProxyMiddleware({
target: 'http://localhost:8080', // Server address
changeOrigin: true
})
);
}

Then, change the request in App.js from http://localhost:8080/api/pokemon/list to /api/pokemon/list.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import './App.css';
import {useEffect, useState} from "react";

function App() {
const [pokemonList, setPokemonList] = useState([]);
useEffect(() => {
fetch('/api/pokemon/list')
.then(response => response.json())
.then(data => setPokemonList(data));
}, []);

return (
<div>
<div>Pokemon List:</div>
<ol>
{pokemonList.map(pokemon => <li key={pokemon.name}>{pokemon.name} ({pokemon.type})</li>)}
</ol>
</div>
);
}

export default App;

Restart npm and refresh the page. We successfully fetched the data. In the Network panel, we can see that the request URL is http://localhost:3000/api/pokemon/list, and the Proxy forwarded the request to http://localhost:8080/api/pokemon/list.

Packaging React into Spring Boot

In Edit Configurations..., add an npm run build configuration. Select run for the Command and build for the scripts.

After adding, run the build. npm will place the compiled React files in the web/build folder. You can place all the files under build (excluding the build folder) on an HTTP server to run the React application. However, we do not want to deploy two web servers in production. The back-end web container also has the ability to serve static resources, so we can manage the React files together with Spring Boot.

Copy all files from web/build/ to src/main/resources/static/. Restart Tomcat and open http://localhost:8080. You will see that React runs normally and successfully fetches the data.

Clicking package under Lifecycle in the Maven panel will package Spring Boot into a WAR file. You can find pokemon-0.0.1-SNAPSHOT.war under the target folder. Deploy this WAR file to the production environment’s web container to run it.

The packaging process requires the following three steps:

  1. Run npm run build to compile React into web/build/.
  2. Copy all files from web/build/ to src/main/resources/static/.
  3. Run Maven’s package command to generate the WAR file.

Why not automate these three steps so that Maven automatically executes the first two steps during packaging?

Automating React Compilation Before Maven Packaging

To make Maven run the npm run build command and copy the compiled front-end files, we need to add two Maven plugins: exec-maven-plugin to run commands and maven-resources-plugin to copy files. Add the following two plugin tags under the project → build → plugins node in pom.xml.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<!-- Plugin to run npm commands -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<id>exec-npm-run-build</id>
<phase>generate-resources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>npm</executable>
<arguments>
<argument>run</argument>
<argument>build</argument>
</arguments>
<workingDirectory>${project.basedir}/web</workingDirectory>
</configuration>
</execution>
</executions>
</plugin>

<!-- Plugin to copy built files to static folder in WAR -->
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>3.2.0</version>
<executions>
<execution>
<id>copy-resources</id>
<phase>process-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<!-- WAR projects put static resources in webapp directory -->
<outputDirectory>${project.build.directory}/classes/static</outputDirectory>
<resources>
<resource>
<directory>${project.basedir}/web/build/</directory>
<includes>
<include>**/*</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>

Clear the front-end files copied to src/main/resources/static/ in the previous step, as the plugin will copy the front-end files to target/classes/static/ and package them. There is no need to copy them to src/main/resources/static/ first.

To prevent residual files from causing issues, usually click clean under Lifecycle in the Maven panel to clean the target folder, then click package. After a short wait, you can find the WAR file in the target folder. Make a copy of the WAR file, change its extension to .zip, and open it with a compression tool. You will see that the React files have been copied to WEB-INF/classes/static/ in the WAR file.

Testing the WAR File

In Edit Configurations…, select Tomcat, click the Copy Configuration button to duplicate the Tomcat configuration, and add Test to the name. In the Deployment tab, delete the web exploded artifact, add External Source..., select the WAR file in the target directory, and change the Application context to /.


Save and run. The WAR file runs successfully.

Fixing the 404 Error After Refreshing the Page in React Router

This issue does not occur when you run npm and Tomcat locally, but when you package the application into a WAR file and deploy it to a web container, users may encounter a 404 error after clicking a link and refreshing the page. Let’s reproduce this error.

In the Terminal panel of IntelliJ IDEA, execute the following commands. First, navigate to the web directory, then install react-router and react-router-dom. Note: The npm command must be run in the directory containing package.json.

1
2
cd web
npm install --save react-router-dom

Modify web/src/index.js to add the BrowserRouter tag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter } from "react-router-dom";

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<BrowserRouter>
<App/>
</BrowserRouter>
</React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

Modify src/App.js to use routing to define two paths and two components:

  1. The root path / corresponds to the React component Home.
  2. /about corresponds to the React component About.

In actual development, you can place Home and About in different files, export them using export, and only keep the routing in App.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import './App.css';
import {useEffect, useState} from "react";
import {Routes, Route, Link} from "react-router-dom";


function Home() {
const [pokemonList, setPokemonList] = useState([]);
useEffect(() => {
fetch('/api/pokemon/list')
.then(response => response.json())
.then(data => setPokemonList(data));
}, []);

return (
<div>
<div><Link to="/about">About</Link></div>
<div>Pokemon List:</div>
<ol>
{pokemonList.map(pokemon => <li key={pokemon.name}>{pokemon.name} ({pokemon.type})</li>)}
</ol>
</div>
);
}

function About() {
return <div>Field Guide of Pokemon</div>
}


function App() {
return <Routes>
<Route path="/" element={<Home/>}/>
<Route path="about" element={<About/>}/>
</Routes>
}

export default App;

Open http://localhost:3000/ to display the home page with an About link. Clicking About opens a new page, and the URL changes to http://localhost:3000/about. Refreshing the About page also displays correctly.

Now, let’s package the application into a WAR file, deploy it to Tomcat Test, and start it. Refresh the browser on the About page, and you will see a 404 error.

What’s going on?

React is a Single Page Application (SPA). The browser only loads the index.html page throughout the entire session. The content and changes users see are achieved by JavaScript modifying the DOM of index.html. Communication with the server is done through JSON, and new components are rendered locally in the browser.

When we open http://localhost:8080/, we request the resource at path / from Tomcat. Tomcat defaults to serving index.html from static/, which is the initial page of React. The content is a blank page, and React loads JavaScript to render the Home component and display the content. When you click the About link, React Router removes the Home component and displays the About component’s content, modifying the URL without requesting a new page from Tomcat.

When you click the browser’s refresh button, the browser requests the resource at path /about from the server. However, the server does not have /about defined in the Controller, nor does it have a static resource named about in static/. Therefore, it returns a 404 error.

In Spring MVC, resource responses have a priority: first, dynamic resources from the Controller are sought, then static resources from static/. If no static resource is found, a 404 error is returned.

To solve this, we can return index.html by default when no static resource is found. React Router will render the corresponding page based on the browser’s URL.

To implement this logic, we need to add ResourceHandlers in Spring MVC.

Add the following line to application.properties to specify the location of static resources:

1
spring.web.resources.static-locations=classpath:/static/

Create a configuration package and create a WebMvcConfiguration class with the following code. Any path that does not find a resource will return index.html. The back-end will no longer return 404 errors, and paths that do not find resources will be handled by React Router on the front-end.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package org.chris.pokemon.configuration;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;

import java.io.IOException;

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer
{
@Value("${spring.web.resources.static-locations}")
private String staticLocations;

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry)
{
registry.addResourceHandler("/**")
.addResourceLocations(staticLocations)
.resourceChain(true)
.addResolver(new PathResourceResolver()
{
@Override
protected Resource getResource(String resourcePath, Resource location) throws IOException
{
Resource resource = super.getResource(resourcePath, location);
return resource == null ? super.getResource("index.html", location) : resource;
}
});
}
}

Repackage and redeploy. After refreshing the About page, there is no 404 error, and the About page displays correctly.

End of Article