Maven Build Node Project – Combine Java and Typescript in one project
If you work on a Java team that also builds a frontend, you have probably run into this problem: your backend uses Maven, your frontend uses Node.js, and getting them to play nice together in a single build pipeline is awkward. I ran into this on a project where we had an Angular app sitting inside a Java WAR, and we needed Maven to handle the full build – TypeScript compilation included.
Here is how I set it up using the Frontend Maven Plugin, and how you can do the same.
The Setup: Maven Meets Node.js
The Node.js ecosystem has its own build tools – Webpack, Vite, the Angular CLI, and so on. The good news is they all run through npm (or pnpm, or yarn), which means we can invoke them from Maven if we get Node installed as part of the build.
That is exactly what the Frontend Maven Plugin does. It downloads and installs a local copy of Node.js into your project, so developers do not need Node installed globally. Anyone can clone the repo and run mvn clean install without setting up anything extra.
I will use Angular CLI as the example here, but the same approach works with Webpack, Vite, Grunt, or any other Node-based build tool. You are just running npm commands from Maven.
Project Structure
Before diving into configuration, here is the folder layout I am working with:

Maven Build Node Project - Maven Project Structure
The Angular source code lives under src/main/javascript/. The compiled output goes to ../webapp/dist, which is where Maven picks it up for packaging.
The angular.json File
Every Angular project has an angular.json file at the root. This is where you configure build targets, set paths, and define per-environment settings.
The key property for our setup is outputPath. This tells the Angular CLI where to put the compiled files. In my case, I set it to "../webapp/dist" so the output lands inside the Maven webapp folder:
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"core-ui": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": {
"base": "../webapp/dist"
},
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": ["src/styles.css"],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
}
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "core-ui:build:production"
},
"development": {
"buildTarget": "core-ui:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"assets": [
"src/favicon.ico",
"src/assets"
]
}
}
}
}
}
}
Note: Angular has moved from the old browser builder (@angular-devkit/build-angular:browser) to the application builder. If you are on Angular 17 or later, use the application builder. The browserTarget option in serve has also been renamed to buildTarget.
Also, if you are on a recent Angular version, you might want to switch your test runner from Karma to a faster option like Vitest or Jest. Karma is being phased out of the Angular ecosystem.
The pom.xml Configuration
Here is the pom.xml with the Frontend Maven Plugin configured. I am using version 2.0.0, which is the latest as of 2025:
<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.bitslovers.web</groupId>
<artifactId>maven-build-node-project</artifactId>
<version>1.0.0</version>
<packaging>war</packaging>
<properties>
<node.version>v22.14.0</node.version>
<frontend-maven-plugin.version>2.0.0</frontend-maven-plugin.version>
</properties>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.4.0</version>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
<attachClasses>true</attachClasses>
<classesClassifier>classes</classesClassifier>
<packagingExcludes>src/,node_modules/,dist/</packagingExcludes>
</configuration>
</plugin>
<!-- Frontend Maven Plugin: installs Node, runs npm ci and npm run build -->
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>${frontend-maven-plugin.version}</version>
<configuration>
<workingDirectory>src/main/javascript/</workingDirectory>
<installDirectory>target</installDirectory>
</configuration>
<executions>
<!-- Step 1: Install Node.js and npm -->
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<nodeVersion>${node.version}</nodeVersion>
</configuration>
</execution>
<!-- Step 2: Install dependencies (npm ci for reproducible builds) -->
<execution>
<id>npm ci</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>ci</arguments>
</configuration>
</execution>
<!-- Step 3: Build the Angular app -->
<execution>
<id>npm build</id>
<goals>
<goal>npm</goal>
</goals>
<phase>prepare-package</phase>
<configuration>
<arguments>run build</arguments>
<environmentVariables>
<NODE_ENV>production</NODE_ENV>
</environmentVariables>
</configuration>
</execution>
</executions>
</plugin>
<!-- Copy the compiled frontend assets into the WAR -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
<executions>
<execution>
<id>copy-frontend-assets</id>
<phase>process-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<overwrite>true</overwrite>
<outputDirectory>${project.build.directory}/${project.build.finalName}/</outputDirectory>
<resources>
<resource>
<directory>${project.basedir}/src/main/webapp/</directory>
</resource>
<resource>
<directory>${project.basedir}/src/main/webapp/dist</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
A few things worth pointing out:
npm ci instead of npm install. The ci command does a clean install based on the lockfile. It is faster and more reliable in build environments because it does not mutate package-lock.json. If you do not have a lockfile committed, use install instead.
installDirectory set to target. The plugin downloads Node into the target folder by default. This keeps it out of your source tree and it gets cleaned up with mvn clean.
Node version as a Maven property. Putting ${node.version} in <properties> makes it easy to bump the Node version in one place.
Excluding Files from the WAR
The maven-war-plugin is what packages everything into a WAR file. We need to make sure it does not bundle node_modules/, src/, or dist/ into the archive:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.4.0</version>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
<attachClasses>true</attachClasses>
<classesClassifier>classes</classesClassifier>
<packagingExcludes>src/,node_modules/,dist/</packagingExcludes>
</configuration>
</plugin>
The packagingExcludes option takes a comma-separated list of paths to skip. Adjust these to match your project layout.
Already Have Node Installed Globally?
If your team already has Node.js installed and you do not want the plugin to download a separate copy, the Frontend Maven Plugin does not support that. You would need to use the exec-maven-plugin instead and call npm directly. That works fine, but you lose the version consistency – different developers (or CI servers) might be running different Node versions, which can cause build failures.
I prefer the Frontend Maven Plugin approach because it pins the Node version in the pom.xml. Everyone on the team gets the same build, every time.
Trade-offs
Keeping the frontend and backend in one Maven project has some real benefits. You get a single build command, a single deployment artifact, and your CI pipeline stays simple. It also works well if you want to run static analysis on the JavaScript side with SonarQube.
The main downside is routing. When both the backend and frontend serve from the same context path, you have to be careful about which routes the backend handles and which the frontend handles. You typically solve this by having the backend forward non-API routes to index.html.
If you want to take this further and set up a full CI/CD pipeline, check out how to use GitLab to build your Java application.
Wrapping Up
Combining a Node.js build into Maven is straightforward once you get the Frontend Maven Plugin configured. It installs Node locally, runs your npm scripts, and drops the compiled output right where Maven expects it. One mvn clean install builds both the Java backend and the frontend, and you get a single WAR file ready to deploy.
Comments