Orchestrate Node.js apps in .NET Aspire
In this article, you learn how to use Node.js and Node Package Manager (npm
) apps in a .NET Aspire project. The sample app in this article demonstrates Angular, React, and Vue client experiences. The following .NET Aspire APIs exist to support these scenarios—and they're part of the Aspire.Hosting.NodeJS NuGet package:
The difference between these two APIs is that the former is used to host Node.js apps, while the latter is used to host apps that execute from a package.json file's scripts
section—and the corresponding npm run <script-name>
command.
Tip
The sample source code for this article is available on GitHub, and there are details available on the Code Samples: .NET Aspire with Angular, React and Vue page.
Important
While this article is focused on Single-Page App (SPA) frontend bits, there's an additional Node.js sample available on the Code Samples: .NET Aspire Node.js sample page, that demonstrates how to use Node.js as a server app with express.
Prerequisites
To work with .NET Aspire, you need the following installed locally:
- .NET 8.0 or .NET 9.0
- An OCI compliant container runtime, such as:
- Docker Desktop or Podman. For more information, see Container runtime.
- An Integrated Developer Environment (IDE) or code editor, such as:
- Visual Studio 2022 version 17.9 or higher (Optional)
- Visual Studio Code (Optional)
- C# Dev Kit: Extension (Optional)
- JetBrains Rider with .NET Aspire plugin (Optional)
For more information, see .NET Aspire setup and tooling, and .NET Aspire SDK.
Additionally, you need to install Node.js on your machine. The sample app in this article was built with Node.js version 20.12.2 and npm version 10.5.1. To verify your Node.js and npm versions, run the following commands:
node --version
npm --version
To download Node.js (including npm
), see the Node.js download page.
Clone sample source code
To clone the sample source code from GitHub, run the following command:
git clone https://github.com/dotnet/aspire-samples.git
After cloning the repository, navigate to the samples/AspireWithJavaScript folder:
cd samples/AspireWithJavaScript
From this directory, there are six child directories described in the following list:
- AspireJavaScript.Angular: An Angular app that consumes the weather forecast API and displays the data in a table.
- AspireJavaScript.AppHost: A .NET Aspire project that orchestrates the other apps in this sample. For more information, see .NET Aspire orchestration overview.
- AspireJavaScript.MinimalApi: An HTTP API that returns randomly generated weather forecast data.
- AspireJavaScript.React: A React app that consumes the weather forecast API and displays the data in a table.
- AspireJavaScript.ServiceDefaults: The default shared project for .NET Aspire projects. For more information, see .NET Aspire service defaults.
- AspireJavaScript.Vue: A Vue app that consumes the weather forecast API and displays the data in a table.
Install client dependencies
The sample app demonstrates how to use JavaScript client apps that are built on top of Node.js. Each client app was written either using a npm create
template command or manually. The following table lists the template commands used to create each client app, along with the default port:
App type | Create template command | Default port |
---|---|---|
Angular | npm create @angular@latest |
4200 |
React | Didn't use a template. | PORT env var |
Vue | npm create vue@latest |
5173 |
Tip
You don't need to run any of these commands, since the sample app already includes the clients. Instead, this is a point of reference from which the clients were created. For more information, see npm-init.
To run the app, you first need to install the dependencies for each client. To do so, navigate to each client folder and run npm install
(or the install alias npm i
) commands.
Install Angular dependencies
npm i ./AspireJavaScript.Angular/
For more information on the Angular app, see explore the Angular client.
Install React dependencies
npm i ./AspireJavaScript.React/
For more information on the React app, see explore the React client.
Install Vue dependencies
npm i ./AspireJavaScript.Vue/
For more information on the Vue app, see explore the Vue client.
Run the sample app
To run the sample app, call the dotnet run command given the orchestrator app host AspireJavaScript.AppHost.csproj as the --project
switch:
dotnet run --project ./AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj
The .NET Aspire dashboard launches in your default browser, and each client app endpoint displays under the Endpoints column of the Resources page. The following image depicts the dashboard for this sample app:
The weatherapi
service endpoint resolves to a Swagger UI page that documents the HTTP API. Each client app consumes this service to display the weather forecast data. You can view each client app by navigating to the corresponding endpoint in the .NET Aspire dashboard. Their screenshots and the modifications made from the template starting point are detailed in the following sections.
In the same terminal session that you used to run the app, press Ctrl + C to stop the app.
Explore the app host
To help understand how each client app resource is orchestrated, look to the app host project. The app host requires the Aspire.Hosting.NodeJS NuGet package to host Node.js apps:
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.0.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.0.0" />
<PackageReference Include="Aspire.Hosting.NodeJs" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AspireJavaScript.MinimalApi\AspireJavaScript.MinimalApi.csproj" />
</ItemGroup>
<Target Name="RestoreNpm" BeforeTargets="Build" Condition=" '$(DesignTimeBuild)' != 'true' ">
<ItemGroup>
<PackageJsons Include="..\*\package.json" />
</ItemGroup>
<!-- Install npm packages if node_modules is missing -->
<Message Importance="Normal" Text="Installing npm packages for %(PackageJsons.RelativeDir)" Condition="!Exists('%(PackageJsons.RootDir)%(PackageJsons.Directory)/node_modules')" />
<Exec Command="npm install" WorkingDirectory="%(PackageJsons.RootDir)%(PackageJsons.Directory)" Condition="!Exists('%(PackageJsons.RootDir)%(PackageJsons.Directory)/node_modules')" />
</Target>
</Project>
The project file also defines a build target that ensures that the npm dependencies are installed before the app host is built. The app host code (Program.cs) declares the client app resources using the AddNpmApp(IDistributedApplicationBuilder, String, String, String, String[]) API.
var builder = DistributedApplication.CreateBuilder(args);
var weatherApi = builder.AddProject<Projects.AspireJavaScript_MinimalApi>("weatherapi")
.WithExternalHttpEndpoints();
builder.AddNpmApp("angular", "../AspireJavaScript.Angular")
.WithReference(weatherApi)
.WaitFor(weatherApi)
.WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.PublishAsDockerFile();
builder.AddNpmApp("react", "../AspireJavaScript.React")
.WithReference(weatherApi)
.WaitFor(weatherApi)
.WithEnvironment("BROWSER", "none") // Disable opening browser on npm start
.WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.PublishAsDockerFile();
builder.AddNpmApp("vue", "../AspireJavaScript.Vue")
.WithReference(weatherApi)
.WaitFor(weatherApi)
.WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.PublishAsDockerFile();
builder.Build().Run();
The preceding code:
- Creates a DistributedApplicationBuilder.
- Adds the "weatherapi" service as a project to the app host.
- Marks the HTTP endpoints as external.
- With a reference to the "weatherapi" service, adds the "angular", "react", and "vue" client apps as npm apps.
- Each client app is configured to run on a different container port, and uses the
PORT
environment variable to determine the port. - All client apps also rely on a Dockerfile to build their container image and are configured to express themselves in the publishing manifest as a container from the PublishAsDockerFile API.
- Each client app is configured to run on a different container port, and uses the
For more information on inner-loop networking, see .NET Aspire inner-loop networking overview. For more information on deploying apps, see .NET Aspire manifest format for deployment tool builders.
When the app host orchestrates the launch of each client app, it uses the npm run start
command. This command is defined in the scripts
section of the package.json file for each client app. The start
script is used to start the client app on the specified port. Each client app relies on a proxy to request the "weatherapi" service.
The proxy is configured in:
- The proxy.conf.js file for the Angular client.
- The webpack.config.js file for the React client.
- The vite.config.ts file for the Vue client.
Explore the Angular client
There are several key modifications from the original Angular template. The first is the addition of a proxy.conf.js file. This file is used to proxy requests from the Angular client to the "weatherapi" service.
module.exports = {
"/api": {
target:
process.env["services__weatherapi__https__0"] ||
process.env["services__weatherapi__http__0"],
secure: process.env["NODE_ENV"] !== "development",
pathRewrite: {
"^/api": "",
},
},
};
The .NET Aspire app host sets the services__weatherapi__http__0
environment variable, which is used to resolve the "weatherapi" service endpoint. The preceding configuration proxies HTTP requests that start with /api
to the target URL specified in the environment variable.
The second update is to the package.json file. This file is used to configure the Angular client to run on a different port than the default port. This is achieved by using the PORT
environment variable, and the run-script-os
npm package to set the port.
{
"name": "angular-weather",
"version": "0.0.0",
"engines": {
"node": ">=20.12"
},
"scripts": {
"ng": "ng",
"start": "run-script-os",
"start:win32": "ng serve --port %PORT%",
"start:default": "ng serve --port $PORT",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.1.1",
"@angular/common": "^18.1.1",
"@angular/compiler": "^18.1.1",
"@angular/core": "^18.1.1",
"@angular/forms": "^18.1.1",
"@angular/platform-browser": "^18.1.1",
"@angular/platform-browser-dynamic": "^18.1.1",
"@angular/router": "^18.1.1",
"rxjs": "~7.8.0",
"tslib": "^2.6.3",
"zone.js": "~0.14.8"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.1.1",
"@angular/cli": "^18.1.1",
"@angular/compiler-cli": "^18.1.1",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.2.0",
"karma": "~6.4.3",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.5.3",
"run-script-os": "^1.1.6"
}
}
The scripts
section of the package.json file is used to define the start
script. This script is used by the npm start
command to start the Angular client app. The start
script is configured to use the run-script-os
package to set the port, which delegates to the ng serve
command passing the appropriate --port
switch based on the OS-appropriate syntax.
In order to make HTTP calls to the "weatherapi" service, the Angular client app needs to be configured to provide the Angular HttpClient
for dependency injection. This is achieved by using the provideHttpClient
helper function while configuring the application in the app.config.ts file.
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient()
]
};
Finally, the Angular client app needs to call the /api/WeatherForecast
endpoint to retrieve the weather forecast data. There are several HTML, CSS, and TypeScript updates, all of which are made to the following files:
- app.component.css: Update the CSS to style the table.
- app.component.html: Update the HTML to display the weather forecast data in a table.
- app.component.ts: Update the TypeScript to call the
/api/WeatherForecast
endpoint and display the data in the table.
import { Component, Injectable } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { WeatherForecasts } from '../types/weatherForecast';
@Injectable()
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'weather';
forecasts: WeatherForecasts = [];
constructor(private http: HttpClient) {
http.get<WeatherForecasts>('api/weatherforecast').subscribe({
next: result => this.forecasts = result,
error: console.error
});
}
}
Angular app running
To visualize the Angular client app, navigate to the "angular" endpoint in the .NET Aspire dashboard. The following image depicts the Angular client app:
Explore the React client
The React app wasn't written using a template, and instead was written manually. The complete source code can be found in the dotnet/aspire-samples repository. Some of the key points of interest are found in the src/App.js file:
import { useEffect, useState } from "react";
import "./App.css";
function App() {
const [forecasts, setForecasts] = useState([]);
const requestWeather = async () => {
const weather = await fetch("api/weatherforecast");
console.log(weather);
const weatherJson = await weather.json();
console.log(weatherJson);
setForecasts(weatherJson);
};
useEffect(() => {
requestWeather();
}, []);
return (
<div className="App">
<header className="App-header">
<h1>React Weather</h1>
<table>
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{(
forecasts ?? [
{
date: "N/A",
temperatureC: "",
temperatureF: "",
summary: "No forecasts",
},
]
).map((w) => {
return (
<tr key={w.date}>
<td>{w.date}</td>
<td>{w.temperatureC}</td>
<td>{w.temperatureF}</td>
<td>{w.summary}</td>
</tr>
);
})}
</tbody>
</table>
</header>
</div>
);
}
export default App;
The App
function is the entry point for the React client app. It uses the useState
and useEffect
hooks to manage the state of the weather forecast data. The fetch
API is used to make an HTTP request to the /api/WeatherForecast
endpoint. The response is then converted to JSON and set as the state of the weather forecast data.
const HTMLWebpackPlugin = require("html-webpack-plugin");
module.exports = (env) => {
return {
entry: "./src/index.js",
devServer: {
port: env.PORT || 4001,
allowedHosts: "all",
proxy: [
{
context: ["/api"],
target:
process.env.services__weatherapi__https__0 ||
process.env.services__weatherapi__http__0,
pathRewrite: { "^/api": "" },
secure: false,
},
],
},
output: {
path: `${__dirname}/dist`,
filename: "bundle.js",
},
plugins: [
new HTMLWebpackPlugin({
template: "./src/index.html",
favicon: "./src/favicon.ico",
}),
],
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: [
"@babel/preset-env",
["@babel/preset-react", { runtime: "automatic" }],
],
},
},
},
{
test: /\.css$/,
exclude: /node_modules/,
use: ["style-loader", "css-loader"],
},
],
},
};
};
The preceding code defines the module.exports
as follows:
- The
entry
property is set to the src/index.js file. - The
devServer
relies on a proxy to forward requests to the "weatherapi" service, sets the port to thePORT
environment variable, and allows all hosts. - The
output
results in a dist folder with a bundle.js file. - The
plugins
set the src/index.html file as the template, and expose the favicon.ico file.
The final updates are to the following files:
- App.css: Update the CSS to style the table.
- App.js: Update the JavaScript to call the
/api/WeatherForecast
endpoint and display the data in the table.
React app running
To visualize the React client app, navigate to the "react" endpoint in the .NET Aspire dashboard. The following image depicts the React client app:
Explore the Vue client
There are several key modifications from the original Vue template. The primary updates were the addition of the fetch
call in the TheWelcome.vue file to retrieve the weather forecast data from the /api/WeatherForecast
endpoint. The following code snippet demonstrates the fetch
call:
<script lang="ts">
interface WeatherForecast {
date: string
temperatureC: number
temperatureF: number
summary: string
};
type Forecasts = WeatherForecast[];
export default {
name: 'TheWelcome',
data() {
return {
forecasts: [],
loading: true,
error: null
}
},
mounted() {
fetch('api/weatherforecast')
.then(response => response.json())
.then(data => {
this.forecasts = data
})
.catch(error => {
this.error = error
})
.finally(() => (this.loading = false))
}
}
</script>
<template>
<table>
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
<tr v-for="forecast in (forecasts as Forecasts)">
<td>{{ forecast.date }}</td>
<td>{{ forecast.temperatureC }}</td>
<td>{{ forecast.temperatureF }}</td>
<td>{{ forecast.summary }}</td>
</tr>
</tbody>
</table>
</template>
<style>
table {
border: none;
border-collapse: collapse;
}
th {
font-size: x-large;
font-weight: bold;
border-bottom: solid .2rem hsla(160, 100%, 37%, 1);
}
th,
td {
padding: 1rem;
}
td {
text-align: center;
font-size: large;
}
tr:nth-child(even) {
background-color: var(--vt-c-black-soft);
}
</style>
As the TheWelcome
integration is mounted
, it calls the /api/weatherforecast
endpoint to retrieve the weather forecast data. The response is then set as the forecasts
data property. To set the server port, the Vue client app uses the PORT
environment variable. This is achieved by updating the vite.config.ts file:
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
host: true,
port: parseInt(process.env.PORT ?? "5173"),
proxy: {
'/api': {
target: process.env.services__weatherapi__https__0 || process.env.services__weatherapi__http__0,
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''),
secure: false
}
}
}
})
Additionally, the Vite config specifies the server.proxy
property to forward requests to the "weatherapi" service. This is achieved by using the services__weatherapi__http__0
environment variable, which is set by the .NET Aspire app host.
The final update from the template is made to the TheWelcome.vue file. This file calls the /api/WeatherForecast
endpoint to retrieve the weather forecast data, and displays the data in a table. It includes CSS, HTML, and TypeScript updates.
Vue app running
To visualize the Vue client app, navigate to the "vue" endpoint in the .NET Aspire dashboard. The following image depicts the Vue client app:
Deployment considerations
The sample source code for this article is designed to run locally. Each client app deploys as a container image. The Dockerfile for each client app is used to build the container image. Each Dockerfile is identical, using a multistage build to create a production-ready container image.
FROM node:20 as build
WORKDIR /app
COPY package.json package.json
COPY package-lock.json package-lock.json
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/default.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist/weather/browser /usr/share/nginx/html
# Expose the default nginx port
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
The client apps are currently configured to run as true SPA apps, and aren't configured to run in a server-side rendered (SSR) mode. They sit behind nginx, which is used to serve the static files. They use a default.conf.template file to configure nginx to proxy requests to the client app.
server {
listen ${PORT};
listen [::]:${PORT};
server_name localhost;
access_log /var/log/nginx/server.access.log main;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass ${services__weatherapi__https__0};
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
rewrite ^/api(/.*)$ $1 break;
}
}
Node.js server app considerations
While this article focuses on client apps, you might have scenarios where you need to host a Node.js server app. The same semantics are required to host a Node.js server app as a SPA client app. The .NET Aspire app host requires a package reference to the Aspire.Hosting.NodeJS NuGet package and the code needs to call either AddNodeApp
or AddNpmApp
. These APIs are useful for adding existing JavaScript apps to the .NET Aspire app host.
When configuring secrets and passing environment variables to JavaScript-based apps, whether they are client or server apps, use parameters. For more information, see .NET Aspire: External parameters—secrets.
Use the OpenTelemetry JavaScript SDK
To export OpenTelemetry logs, traces, and metrics from a Node.js server app, you use the OpenTelemetry JavaScript SDK.
For a complete example of a Node.js server app using the OpenTelemetry JavaScript SDK, you can refer to the Code Samples: .NET Aspire Node.js sample page. Consider the sample's instrumentation.js file, which demonstrates how to configure the OpenTelemetry JavaScript SDK to export logs, traces, and metrics:
import { env } from 'node:process';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-grpc';
import { SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
import { RedisInstrumentation } from '@opentelemetry/instrumentation-redis-4';
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';
import { credentials } from '@grpc/grpc-js';
const environment = process.env.NODE_ENV || 'development';
// For troubleshooting, set the log level to DiagLogLevel.DEBUG
//diag.setLogger(new DiagConsoleLogger(), environment === 'development' ? DiagLogLevel.INFO : DiagLogLevel.WARN);
const otlpServer = env.OTEL_EXPORTER_OTLP_ENDPOINT;
if (otlpServer) {
console.log(`OTLP endpoint: ${otlpServer}`);
const isHttps = otlpServer.startsWith('https://');
const collectorOptions = {
credentials: !isHttps
? credentials.createInsecure()
: credentials.createSsl()
};
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter(collectorOptions),
metricReader: new PeriodicExportingMetricReader({
exportIntervalMillis: environment === 'development' ? 5000 : 10000,
exporter: new OTLPMetricExporter(collectorOptions),
}),
logRecordProcessor: new SimpleLogRecordProcessor({
exporter: new OTLPLogExporter(collectorOptions)
}),
instrumentations: [
new HttpInstrumentation(),
new ExpressInstrumentation(),
new RedisInstrumentation()
],
});
sdk.start();
}
Tip
To configure the .NET Aspire dashboard OTEL CORS settings, see the .NET Aspire dashboard OTEL CORS settings page.
Summary
While there are several considerations that are beyond the scope of this article, you learned how to build .NET Aspire projects that use Node.js and Node Package Manager (npm
). You also learned how to use the AddNpmApp APIs to host Node.js apps and apps that execute from a package.json file, respectively. Finally, you learned how to use the npm
CLI to create Angular, React, and Vue client apps, and how to configure them to run on different ports.
See also
.NET Aspire