How to package your frontend in a Spring Boot App with ParcelJS

No Comments

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.

<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.github.thomasdarimont.training</groupId>
	<artifactId>acme-example</artifactId>
	<version>1.0.0.0-SNAPSHOT</version>
	<packaging>pom</packaging>
 
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.1.2.RELEASE</version>
		<relativePath /> <!-- lookup parent from repository -->
	</parent>
 
	<modules>
		<module>acme-example-api</module>
		<module>acme-example-ui</module>
		<module>acme-example-app</module>
	</modules>
 
	<properties>
		<java.version>11</java.version>
		<maven.compiler.source>${java.version}</maven.compiler.source>
		<maven.compiler.target>${java.version}</maven.compiler.target>
		<maven.compiler.release>${java.version}</maven.compiler.release>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
	</properties>
 
	<dependencies>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
	</dependencies>
 
	<dependencyManagement>
		<dependencies>
			<dependency>
				<groupId>com.github.thomasdarimont.training</groupId>
				<artifactId>acme-example-api</artifactId>
				<version>${project.version}</version>
			</dependency>
 
			<dependency>
				<groupId>com.github.thomasdarimont.training</groupId>
				<artifactId>acme-example-ui</artifactId>
				<version>${project.version}</version>
			</dependency>
		</dependencies>
	</dependencyManagement>
 
	<build>
		<pluginManagement>
			<plugins>
				<plugin>
					<groupId>org.springframework.boot</groupId>
					<artifactId>spring-boot-maven-plugin</artifactId>
					<configuration>
						<executable>true</executable>
					</configuration>
					<executions>
						<execution>
							<goals>
								<goal>build-info</goal>
							</goals>
						</execution>
					</executions>
				</plugin>
				<plugin>
					<groupId>pl.project13.maven</groupId>
					<artifactId>git-commit-id-plugin</artifactId>
					<configuration>
						<generateGitPropertiesFile>true</generateGitPropertiesFile>
						<!-- enables other plugins to use git properties -->
						<injectAllReactorProjects>true</injectAllReactorProjects>
					</configuration>
				</plugin>
			</plugins>
		</pluginManagement>
	</build>
</project>

The API module

The GreetingController class in the acme-example-api module:

package com.acme.app.api;
 
import java.util.Map;
 
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
 
import lombok.extern.slf4j.Slf4j;
 
@Slf4j
@RestController
@RequestMapping("/api/greetings")
class GreetingController {
 
	@GetMapping
	Object greet(@RequestParam(defaultValue = "world") String name) {
		Map<String, Object> data = Map.of("greeting", "Hello " + name, "time", System.currentTimeMillis());
		log.info("Returning: {}", data);
		return data;
	}
}

The Maven build pom.xml is straightforward:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>com.github.thomasdarimont.training</groupId>
		<artifactId>acme-example</artifactId>
		<version>1.0.0.0-SNAPSHOT</version>
	</parent>
	<artifactId>acme-example-api</artifactId>
 
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
 
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>
 
</project>

The APP module

The App class from the acme-example-app module starts the actual Spring Boot infrastructure:

package com.acme.app;
 
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
 
@SpringBootApplication
public class App {
 
	public static void main(String[] args) {
		SpringApplication.run(App.class, args);
	}
}

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:

package com.acme.app.web;
 
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
import lombok.RequiredArgsConstructor;
 
@Configuration
@RequiredArgsConstructor
class WebMvcConfig implements WebMvcConfigurer {
 
	@Override
	public void addResourceHandlers(ResourceHandlerRegistry registry) {
		registry.addResourceHandler("/app/**").addResourceLocations("classpath:/public/");
	}
 
	@Override
	public void addViewControllers(ViewControllerRegistry registry) {
		registry.addViewController("/app/").setViewName("forward:/app/index.html");
	}
}

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:

<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>com.github.thomasdarimont.training</groupId>
		<artifactId>acme-example</artifactId>
		<version>1.0.0.0-SNAPSHOT</version>
	</parent>
	<artifactId>acme-example-app</artifactId>
 
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
			<exclusions>
				<exclusion>
					<groupId>org.springframework.boot</groupId>
					<artifactId>spring-boot-starter-tomcat</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
 
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-jetty</artifactId>
		</dependency>
 
		<dependency>
			<groupId>com.github.thomasdarimont.training</groupId>
			<artifactId>acme-example-api</artifactId>
		</dependency>
 
		<dependency>
			<groupId>com.github.thomasdarimont.training</groupId>
			<artifactId>acme-example-ui</artifactId>
		</dependency>
 
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<optional>true</optional>
		</dependency>
 
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>
 
	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>
</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.

<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>com.github.thomasdarimont.training</groupId>
		<artifactId>acme-example</artifactId>
		<version>1.0.0.0-SNAPSHOT</version>
	</parent>
	<artifactId>acme-example-ui</artifactId>
 
	<properties>
		<node.version>v10.15.1</node.version>
		<yarn.version>v1.13.0</yarn.version>
		<frontend-maven-plugin.version>1.6</frontend-maven-plugin.version>
	</properties>
 
	<build>
		<plugins>
			<plugin>
				<groupId>pl.project13.maven</groupId>
				<artifactId>git-commit-id-plugin</artifactId>
				<!-- config inherited from parent -->
			</plugin>
 
			<plugin>
				<groupId>com.github.eirslett</groupId>
				<artifactId>frontend-maven-plugin</artifactId>
				<version>${frontend-maven-plugin.version}</version>
				<configuration>
					<installDirectory>target</installDirectory>
					<workingDirectory>${basedir}</workingDirectory>
					<nodeVersion>${node.version}</nodeVersion>
					<yarnVersion>${yarn.version}</yarnVersion>
				</configuration>
 
				<executions>
					<execution>
						<id>install node and yarn</id>
						<goals>
							<goal>install-node-and-yarn</goal>
						</goals>
					</execution>
 
					<execution>
						<id>yarn install</id>
						<goals>
							<goal>yarn</goal>
						</goals>
						<configuration>
                                                        <!-- this calls yarn install -->
							<arguments>install</arguments>
						</configuration>
					</execution>
 
					<execution>
						<id>yarn build</id>
						<goals>
							<goal>yarn</goal>
						</goals>
						<configuration>
                                                        <!-- this calls yarn build -->
							<arguments>build</arguments>
						</configuration>
					</execution>
				</executions>
			</plugin>
		</plugins>
 
		<pluginManagement>
			<plugins>
				<!--This plugin's configuration is used to store Eclipse m2e settings 
					only. It has no influence on the Maven build itself. -->
				<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>[0,)</versionRange>
										<goals>
											<goal>install-node-and-yarn</goal>
											<goal>yarn</goal>
										</goals>
									</pluginExecutionFilter>
									<action>
										<!-- ignore yarn builds triggered by eclipse -->
										<ignore />
									</action>
								</pluginExecution>
							</pluginExecutions>
						</lifecycleMappingMetadata>
					</configuration>
				</plugin>
			</plugins>
		</pluginManagement>
	</build>
</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:

<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<title>Acme App</title>
	<meta name="description" content="">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<link rel="stylesheet" href="./style/main.css">
</head>
<body>
	<h1>Acme App</h1>
 
	<button id="btnGetData">Fetch data</button>
	<div id="responseText"></div>
	<script src="./main/main.js" defer></script>
</body>
</html>

The JavaScript code in main.js just calls our small GreetingController from before:

import "@babel/polyfill";
 
function main(){
	console.log("Initializing app...")
 
	btnGetData.onclick = async () => {
 
		const resp = await fetch("../api/greetings");
		const payload = await resp.json();
		console.log(payload);
 
		responseText.innerText=JSON.stringify(payload);
	};
}
 
main();

Note that I’m using ES7 syntax here.

The CSS in main.css is nothing fancy either…

body {
	--main-fg-color: red;
	--main-bg-color: yellow;
}
 
h1 {
	color: var(--main-fg-color);
}
 
#responseText {
	background: var(--main-bg-color);
}

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:

{
	"name": "acme-example-ui-plain",
	"version": "1.0.0.0-SNAPSHOT",
	"private": true,
	"license": "Apache-2.0",
	"scripts": {
		"clean": "rm -rf target/classes/public",
		"start": "parcel --public-url ./ -d target/classes/public src/main/frontend/index.html",
		"watch": "parcel watch --public-url ./ -d target/classes/public src/main/frontend/index.html",
		"build": "parcel build --public-url ./ -d target/classes/public src/main/frontend/index.html"
	},
	"devDependencies": {
		"@babel/core": "^7.0.0-0",
		"@babel/plugin-proposal-async-generator-functions": "^7.2.0",
		"babel-preset-latest": "^6.24.1",
		"parcel": "^1.11.0"
	},
	"dependencies": {
		"@babel/polyfill": "^7.2.5"
	}
}

In order to get support for ES7 features such as async JavaScript functions, we need to configure the babel transpiler via the file .babelrc.

{
   "presets": [
      ["latest"]
   ],
   "plugins": []
}

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:

parcel build --public-url ./ -d target/classes/public src/main/frontend/index.html

This line does several things:

  • --public-url ./
    This instructs parcel 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 the target/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 case src/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

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<meta http-equiv="X-UA-Compatible" content="IE=edge">
		<title>Acme App</title>
		<meta name="description" content="">
		<meta name="viewport" content="width=device-width, initial-scale=1">
		<link rel="stylesheet" href="main.d4190f58.css">
	<script src="main.d4190f58.js"></script></head>
	<body>
		<h1>Acme App</h1>
 
		<button id="btnGetData">Fetch data</button>
		<div id="responseText"></div>
		<script src="main.0632549a.js" defer=""></script>
	</body>
</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

interface Person {
    firstName: string;
    lastName: string;
}
 
function greet(person: Person) {
    return "Hello, " + person.firstName + " " + person.lastName;
}
 
let user = { firstName: "Buddy", lastName: "Holly" };
 
console.log(greet(user));

and reference it in the index.html file.

<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

<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:
ParcelJS Example for Spring Boot Frontend - Live Reload

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.

Thomas Darimont

Thomas Darimont is a Fellow at codecentric AG in MΓΌnster/Germany, where he helps customers implement centralized identity management platforms.
Previously, he worked as a Principal Software Engineer in the Spring Data team at Pivotal. He has over 15 years of experience in the development of Java- and .NET-based enterprise applications and open source projects.
His working focus is centered around software architecture, the Spring ecosystem, performance tuning, and security. In his spare time, he loves organizing community meetups and contributing to open source projects like Keycloak, Spring, and Cloud Foundry.

Comment

Your email address will not be published. Required fields are marked *