There are numerous ways to integrate frontend code in Spring-Boot-based web applications. One of them was recently demonstrated by our blog post A Lovely Spring View: Spring Boot & Vue.js from my colleague Jonas Hecht .
In this blogpost you’ll learn a lean way to integrate frontend code in your Spring Boot app.
The problem
When integrating frontend code, we often have to deal with multiple things like: resources, HTML, CSS, JavaScript, Typescript, minification, etc. – often through the means of complicatedly generated build scripts which are difficult to debug.
I’ve been looking for a simple solution for quick experiments for quite a while now… then I stumbled upon ParcelJS, which solves a part of this by using convention over configuration.
ParcelJS is a simple web application bundler that packages your frontend code with sane defaults that do what you want – at least most of the time. Great for small and simple projects or demo apps.
In the following post I’ll describe how you can bundle and serve your frontend code from within a Spring Boot app without using any proxies, dedicated dev-servers or complicated build systems! And you’ll also get cool stuff like compression, minification and live-reload for free. 🙂
Sounds promising? Then keep reading!
For the impatient, you can find all the code on GitHub here: thomasdarimont/spring-boot-micro-frontend-example
Example application
The example application uses Maven and is composed of three modules wrapped in a fourth parent-module:
acme-example-api
acme-example-ui
acme-example-app
spring-boot-micro-frontend-example
(parent)
The first module is acme-example-api
, which contains the backend API which, in turn, is just a simple @RestController
annotated Spring MVC Controller. Our second module acme-example-ui
contains our frontend code and uses Maven in combination with Parcel to package the application bits. The next module acme-example-app
hosts the actual Spring Boot app and wires the two other modules together. Finally, the spring-boot-starter-parent
module serves as an aggregator module and provides default configuration.
The parent module
The parent module itself uses the spring-boot-starter-parent
as parent and inherits some managed dependencies and default configuration.
1<project xmlns="http://maven.apache.org/POM/4.0.0" 2 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 3 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4 <modelVersion>4.0.0</modelVersion> 5 <groupId>com.github.thomasdarimont.training</groupId> 6 <artifactId>acme-example</artifactId> 7 <version>1.0.0.0-SNAPSHOT</version> 8 <packaging>pom</packaging> 9 10 <parent> 11 <groupId>org.springframework.boot</groupId> 12 <artifactId>spring-boot-starter-parent</artifactId> 13 <version>2.1.2.RELEASE</version> 14 <relativePath /> <!-- lookup parent from repository --> 15 </parent> 16 17 <modules> 18 <module>acme-example-api</module> 19 <module>acme-example-ui</module> 20 <module>acme-example-app</module> 21 </modules> 22 23 <properties> 24 <java.version>11</java.version> 25 <maven.compiler.source>${java.version}</maven.compiler.source> 26 <maven.compiler.target>${java.version}</maven.compiler.target> 27 <maven.compiler.release>${java.version}</maven.compiler.release> 28 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> 29 <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> 30 </properties> 31 32 <dependencies> 33 <dependency> 34 <groupId>org.projectlombok</groupId> 35 <artifactId>lombok</artifactId> 36 <optional>true</optional> 37 </dependency> 38 </dependencies> 39 40 <dependencyManagement> 41 <dependencies> 42 <dependency> 43 <groupId>com.github.thomasdarimont.training</groupId> 44 <artifactId>acme-example-api</artifactId> 45 <version>${project.version}</version> 46 </dependency> 47 48 <dependency> 49 <groupId>com.github.thomasdarimont.training</groupId> 50 <artifactId>acme-example-ui</artifactId> 51 <version>${project.version}</version> 52 </dependency> 53 </dependencies> 54 </dependencyManagement> 55 56 <build> 57 <pluginManagement> 58 <plugins> 59 <plugin> 60 <groupId>org.springframework.boot</groupId> 61 <artifactId>spring-boot-maven-plugin</artifactId> 62 <configuration> 63 <executable>true</executable> 64 </configuration> 65 <executions> 66 <execution> 67 <goals> 68 <goal>build-info</goal> 69 </goals> 70 </execution> 71 </executions> 72 </plugin> 73 <plugin> 74 <groupId>pl.project13.maven</groupId> 75 <artifactId>git-commit-id-plugin</artifactId> 76 <configuration> 77 <generateGitPropertiesFile>true</generateGitPropertiesFile> 78 <!-- enables other plugins to use git properties --> 79 <injectAllReactorProjects>true</injectAllReactorProjects> 80 </configuration> 81 </plugin> 82 </plugins> 83 </pluginManagement> 84 </build> 85</project>
The API module
The GreetingController
class in the acme-example-api
module:
1package com.acme.app.api;
2
3import java.util.Map;
4
5import org.springframework.web.bind.annotation.GetMapping;
6import org.springframework.web.bind.annotation.RequestMapping;
7import org.springframework.web.bind.annotation.RequestParam;
8import org.springframework.web.bind.annotation.RestController;
9
10import lombok.extern.slf4j.Slf4j;
11
12@Slf4j
13@RestController
14@RequestMapping("/api/greetings")
15class GreetingController {
16
17 @GetMapping
18 Object greet(@RequestParam(defaultValue = "world") String name) {
19 Map<String, Object> data = Map.of("greeting", "Hello " + name, "time", System.currentTimeMillis());
20 log.info("Returning: {}", data);
21 return data;
22 }
23}
The Maven build pom.xml
is straightforward:
1<?xml version="1.0" encoding="UTF-8"?> 2<project xmlns="http://maven.apache.org/POM/4.0.0" 3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 4 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 5 <modelVersion>4.0.0</modelVersion> 6 <parent> 7 <groupId>com.github.thomasdarimont.training</groupId> 8 <artifactId>acme-example</artifactId> 9 <version>1.0.0.0-SNAPSHOT</version> 10 </parent> 11 <artifactId>acme-example-api</artifactId> 12 13 <dependencies> 14 <dependency> 15 <groupId>org.springframework.boot</groupId> 16 <artifactId>spring-boot-starter-web</artifactId> 17 </dependency> 18 19 <dependency> 20 <groupId>org.springframework.boot</groupId> 21 <artifactId>spring-boot-starter-test</artifactId> 22 <scope>test</scope> 23 </dependency> 24 </dependencies> 25 26</project>
The APP module
The App
class from the acme-example-app
module starts the actual Spring Boot infrastructure:
1package com.acme.app;
2
3import org.springframework.boot.SpringApplication;
4import org.springframework.boot.autoconfigure.SpringBootApplication;
5
6@SpringBootApplication
7public class App {
8
9 public static void main(String[] args) {
10 SpringApplication.run(App.class, args);
11 }
12}
For our app, we want to serve the frontend resources from within our Spring Boot app.
Therefore, we define the following ResourceHandler
and ViewController
definitions in WebMvcConfig
in the acme-example-app
module:
1package com.acme.app.web;
2
3import org.springframework.context.annotation.Configuration;
4import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
5import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
6import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
7
8import lombok.RequiredArgsConstructor;
9
10@Configuration
11@RequiredArgsConstructor
12class WebMvcConfig implements WebMvcConfigurer {
13
14 @Override
15 public void addResourceHandlers(ResourceHandlerRegistry registry) {
16 registry.addResourceHandler("/app/**").addResourceLocations("classpath:/public/");
17 }
18
19 @Override
20 public void addViewControllers(ViewControllerRegistry registry) {
21 registry.addViewController("/app/").setViewName("forward:/app/index.html");
22 }
23}
To make the example more realistic, we’ll use /acme
as a custom context-path
for our app via the application.yml
in the
server:
servlet:
context-path: /acme
The Maven pom.xml
of our acme-example-app
module looks a bit more wordy as it pulls the other modules together:
1<project xmlns="http://maven.apache.org/POM/4.0.0" 2 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 3 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4 <modelVersion>4.0.0</modelVersion> 5 <parent> 6 <groupId>com.github.thomasdarimont.training</groupId> 7 <artifactId>acme-example</artifactId> 8 <version>1.0.0.0-SNAPSHOT</version> 9 </parent> 10 <artifactId>acme-example-app</artifactId> 11 12 <dependencies> 13 <dependency> 14 <groupId>org.springframework.boot</groupId> 15 <artifactId>spring-boot-starter-web</artifactId> 16 <exclusions> 17 <exclusion> 18 <groupId>org.springframework.boot</groupId> 19 <artifactId>spring-boot-starter-tomcat</artifactId> 20 </exclusion> 21 </exclusions> 22 </dependency> 23 24 <dependency> 25 <groupId>org.springframework.boot</groupId> 26 <artifactId>spring-boot-starter-jetty</artifactId> 27 </dependency> 28 29 <dependency> 30 <groupId>com.github.thomasdarimont.training</groupId> 31 <artifactId>acme-example-api</artifactId> 32 </dependency> 33 34 <dependency> 35 <groupId>com.github.thomasdarimont.training</groupId> 36 <artifactId>acme-example-ui</artifactId> 37 </dependency> 38 39 <dependency> 40 <groupId>org.springframework.boot</groupId> 41 <artifactId>spring-boot-devtools</artifactId> 42 <optional>true</optional> 43 </dependency> 44 45 <dependency> 46 <groupId>org.springframework.boot</groupId> 47 <artifactId>spring-boot-starter-test</artifactId> 48 <scope>test</scope> 49 </dependency> 50 </dependencies> 51 52 <build> 53 <plugins> 54 <plugin> 55 <groupId>org.springframework.boot</groupId> 56 <artifactId>spring-boot-maven-plugin</artifactId> 57 </plugin> 58 </plugins> 59 </build> 60</project>
The UI module
Now comes the interesting part: the acme-example-ui
Maven module which contains our frontend code.
The pom.xml
for the acme-example-ui
module uses the com.github.eirslett:frontend-maven-plugin
Maven plugin to trigger standard frontend build tools, in this case node
and yarn
.
1<project xmlns="http://maven.apache.org/POM/4.0.0" 2 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 3 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4 <modelVersion>4.0.0</modelVersion> 5 <parent> 6 <groupId>com.github.thomasdarimont.training</groupId> 7 <artifactId>acme-example</artifactId> 8 <version>1.0.0.0-SNAPSHOT</version> 9 </parent> 10 <artifactId>acme-example-ui</artifactId> 11 12 <properties> 13 <node.version>v10.15.1</node.version> 14 <yarn.version>v1.13.0</yarn.version> 15 <frontend-maven-plugin.version>1.6</frontend-maven-plugin.version> 16 </properties> 17 18 <build> 19 <plugins> 20 <plugin> 21 <groupId>pl.project13.maven</groupId> 22 <artifactId>git-commit-id-plugin</artifactId> 23 <!-- config inherited from parent --> 24 </plugin> 25 26 <plugin> 27 <groupId>com.github.eirslett</groupId> 28 <artifactId>frontend-maven-plugin</artifactId> 29 <version>${frontend-maven-plugin.version}</version> 30 <configuration> 31 <installDirectory>target</installDirectory> 32 <workingDirectory>${basedir}</workingDirectory> 33 <nodeVersion>${node.version}</nodeVersion> 34 <yarnVersion>${yarn.version}</yarnVersion> 35 </configuration> 36 37 <executions> 38 <execution> 39 <id>install node and yarn</id> 40 <goals> 41 <goal>install-node-and-yarn</goal> 42 </goals> 43 </execution> 44 45 <execution> 46 <id>yarn install</id> 47 <goals> 48 <goal>yarn</goal> 49 </goals> 50 <configuration> 51 <!-- this calls yarn install --> 52 <arguments>install</arguments> 53 </configuration> 54 </execution> 55 56 <execution> 57 <id>yarn build</id> 58 <goals> 59 <goal>yarn</goal> 60 </goals> 61 <configuration> 62 <!-- this calls yarn build --> 63 <arguments>build</arguments> 64 </configuration> 65 </execution> 66 </executions> 67 </plugin> 68 </plugins> 69 70 <pluginManagement> 71 <plugins> 72 <!--This plugin's configuration is used to store Eclipse m2e settings 73 only. It has no influence on the Maven build itself. --> 74 <plugin> 75 <groupId>org.eclipse.m2e</groupId> 76 <artifactId>lifecycle-mapping</artifactId> 77 <version>1.0.0</version> 78 <configuration> 79 <lifecycleMappingMetadata> 80 <pluginExecutions> 81 <pluginExecution> 82 <pluginExecutionFilter> 83 <groupId>com.github.eirslett</groupId> 84 <artifactId>frontend-maven-plugin</artifactId> 85 <versionRange>[0,)</versionRange> 86 <goals> 87 <goal>install-node-and-yarn</goal> 88 <goal>yarn</goal> 89 </goals> 90 </pluginExecutionFilter> 91 <action> 92 <!-- ignore yarn builds triggered by eclipse --> 93 <ignore /> 94 </action> 95 </pluginExecution> 96 </pluginExecutions> 97 </lifecycleMappingMetadata> 98 </configuration> 99 </plugin> 100 </plugins> 101 </pluginManagement> 102 </build> 103</project>
The “frontend” code resides in the directory /acme-example-ui/src/main/frontend
and has the following structure:
└── frontend
├── index.html
├── main
│ └── main.js
└── style
└── main.css
The index.html
contains just plain html that references our JavaScript code and assets:
1<!DOCTYPE html>
2<html>
3<head>
4 <meta charset="utf-8">
5 <meta http-equiv="X-UA-Compatible" content="IE=edge">
6 <title>Acme App</title>
7 <meta name="description" content="">
8 <meta name="viewport" content="width=device-width, initial-scale=1">
9 <link rel="stylesheet" href="./style/main.css">
10</head>
11<body>
12 <h1>Acme App</h1>
13
14 <button id="btnGetData">Fetch data</button>
15 <div id="responseText"></div>
16 <script src="./main/main.js" defer></script>
17</body>
18</html>
The JavaScript code in main.js
just calls our small GreetingController
from before:
1import "@babel/polyfill";
2
3function main(){
4 console.log("Initializing app...")
5
6 btnGetData.onclick = async () => {
7
8 const resp = await fetch("../api/greetings");
9 const payload = await resp.json();
10 console.log(payload);
11
12 responseText.innerText=JSON.stringify(payload);
13 };
14}
15
16main();
Note that I’m using ES7 syntax here.
The CSS in main.css
is nothing fancy either…
1body { 2 --main-fg-color: red; 3 --main-bg-color: yellow; 4} 5 6h1 { 7 color: var(--main-fg-color); 8} 9 10#responseText { 11 background: var(--main-bg-color); 12}
Note that I’m using the “new” native CSS variable support, feels a bit otherworldly, but oh well.
Now to the climax of this “small” post, the package.json
. In this small config we can find some helpful tricks:
1{ 2 "name": "acme-example-ui-plain", 3 "version": "1.0.0.0-SNAPSHOT", 4 "private": true, 5 "license": "Apache-2.0", 6 "scripts": { 7 "clean": "rm -rf target/classes/public", 8 "start": "parcel --public-url ./ -d target/classes/public src/main/frontend/index.html", 9 "watch": "parcel watch --public-url ./ -d target/classes/public src/main/frontend/index.html", 10 "build": "parcel build --public-url ./ -d target/classes/public src/main/frontend/index.html" 11 }, 12 "devDependencies": { 13 "@babel/core": "^7.0.0-0", 14 "@babel/plugin-proposal-async-generator-functions": "^7.2.0", 15 "babel-preset-latest": "^6.24.1", 16 "parcel": "^1.11.0" 17 }, 18 "dependencies": { 19 "@babel/polyfill": "^7.2.5" 20 } 21}
In order to get support for ES7 features such as async
JavaScript functions, we need to configure the babel transpiler via the file .babelrc
.
1{ 2 "presets": [ 3 ["latest"] 4 ], 5 "plugins": [] 6}
The ParcelJS setup
We declare some scripts for clean
,start
,watch
and build
in order to be able to call them via `yarn` or `npm`.
The next trick is the configuration of parcel. Let’s look at a concrete example to see what’s going on here:
1parcel build --public-url ./ -d target/classes/public src/main/frontend/index.html
This line does several things:
--public-url ./
This instructsparcel
to generate links relative to the path where we’ll serve the app resources from.-d target/classes/public
This tells Parcel to place the frontend artifacts in thetarget/classes/public
folder where they… drumroll… can be found on the classpath 🙂src/main/frontend/index.html
The last part is to show Parcel where the entry point of our application is, in this casesrc/main/frontend/index.html
. Note that you could define multiple entry points here.
The next trick is to combine this configuration with Parcel’s watch mode, which can be started via the parcel watch
command.
As with many other web application bundler tools such as webpack
, the watch allows to automatically and transparently recompile and repackage frontend artifacts whenever we change code.
So all we have to do to have a smooth frontend developer experience is to start a `yarn watch` process in the /acme-example-ui
folder.
The generated resources will appear under target/classes/public
and look like this:
$ yarn watch
yarn run v1.13.0
$ parcel watch --public-url ./ -d target/classes/public src/main/frontend/index.html
✨ Built in 585ms.
$ ll target/classes/public
total 592K
drwxr-xr-x. 2 tom tom 4,0K 8. Feb 22:59 ./
drwxr-xr-x. 3 tom tom 4,0K 8. Feb 22:59 ../
-rw-r--r--. 1 tom tom 525 8. Feb 23:02 index.html
-rw-r--r--. 1 tom tom 303K 8. Feb 23:02 main.0632549a.js
-rw-r--r--. 1 tom tom 253K 8. Feb 23:02 main.0632549a.map
-rw-r--r--. 1 tom tom 150 8. Feb 23:02 main.d4190f58.css
-rw-r--r--. 1 tom tom 9,5K 8. Feb 23:02 main.d4190f58.js
-rw-r--r--. 1 tom tom 3,6K 8. Feb 23:02 main.d4190f58.map
$ cat target/classes/public/index.html
yields
1<!DOCTYPE html>
2<html>
3 <head>
4 <meta charset="utf-8">
5 <meta http-equiv="X-UA-Compatible" content="IE=edge">
6 <title>Acme App</title>
7 <meta name="description" content="">
8 <meta name="viewport" content="width=device-width, initial-scale=1">
9 <link rel="stylesheet" href="main.d4190f58.css">
10 <script src="main.d4190f58.js"></script></head>
11 <body>
12 <h1>Acme App</h1>
13
14 <button id="btnGetData">Fetch data</button>
15 <div id="responseText"></div>
16 <script src="main.0632549a.js" defer=""></script>
17 </body>
18</html>
The next trick is to just use Spring Boot devtools
with Live-reload enabled. This will automatically reload the package contents if you touched any frontend code.
You can start the com.acme.app.App
as a Spring Boot app and access the app by entering the URL http://localhost:8080/acme/app/
in your browser.
Adding Typescript to the mix
Now that we have our setup working, we might want to use Typescript instead of plain JavaScript. With Parcel this is quite easy.
Just add a new file to src/main/frontend/main
with the name hello.ts
1interface Person {
2 firstName: string;
3 lastName: string;
4}
5
6function greet(person: Person) {
7 return "Hello, " + person.firstName + " " + person.lastName;
8}
9
10let user = { firstName: "Buddy", lastName: "Holly" };
11
12console.log(greet(user));
and reference it in the index.html
file.
1<script src="./main/hello.ts" defer></script>
Since we’re running yarn watch
, the parcel
tool will figure out that we need a Typescript compiler based on the .ts
file extension of our referenced file. Therefore ParcelJS will automatically add "typescript": "^3.3.3"
to our devDependencies
in the package.json
file. That’s it!
Using less for CSS
We now might want to use less
instead of plain css
. Again, all we have to do here is rename main.css
to main.less
and refer to it in the index.html
file via
1<link rel="stylesheet" href="./style/main.less">
ParcelJS will automatically add "less": "^3.9.0"
to our devDependencies
and provides you with a ready to use configuration that just works.
I don’t know about you, but this blew my mind when I saw it for the first time. Note that ParcelJS supports a lot of other asset types by default.
Once you are done with your app, you can just do a maven verify
, which will automatically build your acme-example-api
and acme-example-ui
module and package it in the executable acme-example-app
JAR.
Here is the tooling in action:
Next time you want to build something quick or just hack around a bit, then ParcelJS and Spring Boot might be a good fit for you.
More articles
fromThomas Darimont
Your job at codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
More articles in this subject area
Discover exciting further topics and let the codecentric world inspire you.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog author
Thomas Darimont
Principal IAM Consultant
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.