Lewati ke konten
Docs Try Aspire
Docs Try

Deploy JavaScript apps

Konten ini belum tersedia dalam bahasa Anda.

JavaScript app resources can represent static browser frontends, Node.js servers, SSR frameworks, and specialized resources such as Next.js apps. The deployment decision is less about which AppHost API you started with and more about which resource owns the public HTTP surface in production.

Use this table as the starting point:

What are you deploying?Production entrypointAspire pattern
Static frontend served by a backendBackend, API, or web serverPublishWithContainerFiles
Static frontend served by a gateway or BFFReverse proxyPublishWithStaticFiles
Static frontend served by its own JavaScript resourceJavaScript app resourcePublishAsStaticWebsite
SSR or Node.js app with a built server artifactJavaScript app resourcePublishAsNodeServer
SSR or Node.js app started by a package scriptJavaScript app resourcePublishAsNpmScript
Next.js standalone appNext.js app resourceAddNextJsApp
Static frontend hosted separately from the backendStatic host + separate APICustom topology

This article is deployment-target agnostic. It explains the JavaScript hosting models you can use with Aspire, not the full steps for a specific deployment target.

For deployment, most JavaScript app resources should be treated as build-only resources until you choose a production serving model. Different app resource APIs provide different defaults, such as Vite conventions, direct Node.js script execution, command-driven JavaScript apps, or Next.js standalone output.

To deploy a JavaScript app, choose which resource owns the public HTTP surface in production:

  • Use PublishWithContainerFiles(...) when your backend or web server serves the built frontend files.
  • Use PublishWithStaticFiles(...) when your reverse proxy, gateway, or BFF serves the built frontend files.
  • Use a JavaScript publish method when the JavaScript app resource should become the deployed static website or Node.js server.
  • Use AddNextJsApp(...) for a Next.js app that publishes as a standalone Next.js server.

If you only add a Vite or JavaScript app and reference backend services, Aspire still needs one of these production hosting patterns to know who serves requests in deployment. During publish or deploy, Aspire validates that build-only containers are either consumed by another resource through PublishWithContainerFiles(...) or PublishWithStaticFiles(...), or converted into a deployable JavaScript resource with a JavaScript publish method. An unconsumed build-only container does not participate in deployment.

Use this shape when your backend, API, or web server is responsible for serving static frontend files in production from wwwroot, static, or a similar directory.

This shape only works if the deployed application service can actually serve those files. PublishWithContainerFiles(...) copies the built frontend assets into the destination container; it does not configure the destination app to serve them.

flowchart LR
    Browser --> App["Node app<br/>serving built frontend files"]
    App --> Frontend["Vite build output"]
AppHost.cs
var builder = DistributedApplication.CreateBuilder(args);
var app = builder
.AddNodeApp("app", "./api", "src/index.js")
.WithHttpEndpoint(port: 3000, env: "PORT")
.WithExternalHttpEndpoints();
var frontend = builder
.AddViteApp("frontend", "./frontend")
.WithReference(app)
.WaitFor(app);
app.PublishWithContainerFiles(frontend, "./static");
builder.Build().Run();
  1. AddViteApp runs the Vite dev server during aspire run.
  2. During publish, Aspire builds the frontend and extracts its production output.
  3. PublishWithContainerFiles copies those files into the backend or web server container.
  4. The backend or web server becomes the deployed HTTP endpoint and serves the frontend files.
  • Your backend already serves static files, or you are willing to make it do so.
  • You want one deployed service to host both the API and the frontend.
  • You want the same resource to own routing, auth, headers, fallback behavior, and static asset hosting.
  • The backend container gets larger because it contains both backend code and frontend assets.
  • Frontend and backend are deployed together, which is convenient when they change together but less flexible if you want to scale or release them independently.
  • Authentication, caching, headers, and fallback routing are handled where the backend serves the files.
  • This usually gives the simplest mental model: one deployed service, one public endpoint, one place to troubleshoot.

Static frontend served by a gateway or BFF

Section titled “Static frontend served by a gateway or BFF”

Use this shape when a reverse proxy should be the public entrypoint for your app, either as a gateway or as a BFF. In Aspire, YARP is the built-in example, but the same topology applies when you use another reverse proxy such as Nginx or Caddy.

flowchart LR
    Browser --> YARP["YARP"]
    YARP --> API["API routes"]
    YARP --> Frontend["Frontend routes"]
AppHost.cs
var builder = DistributedApplication.CreateBuilder(args);
var api = builder
.AddNodeApp("api", "./api", "src/index.js")
.WithHttpEndpoint(port: 3000, env: "PORT");
var frontend = builder
.AddViteApp("frontend", "./frontend");
builder
.AddYarp("app")
.WithConfiguration(c =>
{
c.AddRoute("/api/{**catch-all}", api)
.WithTransformPathRemovePrefix("/api");
})
.WithExternalHttpEndpoints()
.PublishWithStaticFiles(frontend);
builder.Build().Run();

AddViteApp is still fine in this shape, but the Vite development server endpoint is not used at publish time.

If your gateway or BFF needs to know about the frontend dev server during local development, gate that wiring to run mode only:

AppHost.cs
var frontend = builder
.AddViteApp("frontend", "./frontend");
var gateway = builder
.AddYarp("app")
.WithExternalHttpEndpoints()
.PublishWithStaticFiles(frontend);
if (builder.ExecutionContext.IsRunMode)
{
gateway.WaitFor(frontend);
gateway.WithEnvironment("FRONTEND_DEV_URL", frontend.GetEndpoint("http"));
}
  1. The reverse proxy owns the public URL surface for both frontend and backend routes.
  2. API requests such as /api/* are routed to the backend service.
  3. During publish, Aspire builds the frontend and PublishWithStaticFiles copies the output into the proxy resource.
  4. In production, the proxy serves frontend routes itself while continuing to proxy API routes.
  • You want a gateway or BFF in front of your application.
  • You already use a reverse proxy for API routing, aggregation, path transforms, or BFF-style concerns.
  • You want one public endpoint in both development and production.
  • Backend services can stay internal behind the gateway or BFF.
  • Frontend hosting is decoupled from any individual backend service, which can make routing cleaner in multi-service apps.
  • Route rules now matter directly because the proxy decides which requests go to APIs and which requests go to the frontend.
  • You now have a dedicated gateway/BFF in the deployment, which adds one more moving part but also gives you more control over ingress behavior.

Static frontend served by its own JavaScript resource

Section titled “Static frontend served by its own JavaScript resource”

Use PublishAsStaticWebsite when the framework produces static files and you want the JavaScript app resource to become the deployed static website. The generated container uses YARP to serve the built files and can optionally proxy API requests to a backend resource using service discovery.

Choose this shape instead of PublishWithStaticFiles(...) when you do not already have a gateway or BFF resource that should own the public route table. If you already have an explicit YARP resource for gateway or BFF behavior, keep using PublishWithStaticFiles(...) on that resource.

AppHost.cs
#pragma warning disable ASPIREJAVASCRIPT001
var builder = DistributedApplication.CreateBuilder(args);
builder.AddDockerComposeEnvironment("compose");
var api = builder
.AddNodeApp("api", "./frameworks/api", "server.js")
.WithHttpEndpoint(port: 3001, env: "PORT")
.WithExternalHttpEndpoints();
var frontend = builder.AddViteApp("react", "./frameworks/react", runScriptName: "dev");
frontend.PublishAsStaticWebsite(
apiPath: "/api",
apiTarget: api)
.WithExternalHttpEndpoints();
builder.Build().Run();
OptionDefaultDescription
OutputPath / outputPathdistThe build output directory that contains the static files to serve.
StripPrefix / stripPrefixfalseWhether to remove the API path prefix before forwarding to the API backend. Set to true if the API does not expect the route prefix in the request path.
TargetEndpointName / targetEndpointNamenullThe endpoint name on the API resource to use for proxying. When unset, YARP uses service discovery and prefers HTTPS when available.

You can call PublishAsStaticWebsite without an API target when the static website does not need proxy routes. Add apiPath and apiTarget when the static website should also route API requests to a backend resource.

PublishAsStaticWebsite only takes effect at publish time. In run mode, each framework still uses its own dev server, and the browser hits the dev server’s origin instead of YARP. To keep the same /api/* shape working in development, the dev server itself needs a small proxy config that forwards /api/* to the backend resource.

When the frontend references a backend resource, either explicitly through WithReference or implicitly when you pass apiTarget to PublishAsStaticWebsite, Aspire exposes the backend URL through service-discovery environment variables. The variable name follows the pattern <RESOURCENAME>_<SCHEME> in upper case. For a backend resource named api with an http endpoint, that’s API_HTTP. If you rename the resource, for example weather, or use https, the variable becomes WEATHER_HTTP or API_HTTPS. apiTarget adds the reference for you, so no extra WithReference call is required when you use PublishAsStaticWebsite(apiPath, apiTarget).

Each framework reads that variable from its own dev-server config:

  • Vite, React, Vue: Add server.proxy in vite.config.ts and read process.env.API_HTTP.
  • Astro: Add vite.server.proxy in astro.config.mjs and read process.env.API_HTTP.
  • Angular: Add a proxy.conf.js (not .json) that reads process.env.API_HTTP, then reference it from angular.json under serve.options.proxyConfig.

Once that’s in place, /api/* resolves to the backend in both dev mode through the framework’s dev proxy and production through YARP. This avoids VITE_* build-time variables and CORS configuration for this shape. Substitute the actual variable name your resource produces (<RESOURCENAME>_<SCHEME>) if you don’t name your backend api.

SSR or Node.js app served by its own JavaScript resource

Section titled “SSR or Node.js app served by its own JavaScript resource”

Use this shape when the JavaScript framework output should become the deployed web server and you do not need to attach the build output to a separate backend or gateway resource.

There are two common runtime shapes:

  • Use PublishAsNodeServer when the build produces a self-contained server artifact that can run directly with node.
  • Use PublishAsNpmScript when the production server starts through a package-manager script and needs production dependencies from node_modules.

Use PublishAsNodeServer for frameworks that produce a self-contained Node.js server artifact during build, such as SvelteKit and TanStack Start. Aspire generates a runtime container that runs the built artifact directly with node.

Choose this method instead of PublishAsNpmScript when the build output does not need a production node_modules install at runtime. The resulting image can be smaller because it copies the server artifact rather than the full application with production dependencies.

AppHost.cs
#pragma warning disable ASPIREJAVASCRIPT001
var builder = DistributedApplication.CreateBuilder(args);
builder.AddDockerComposeEnvironment("compose");
var api = builder
.AddNodeApp("api", "./frameworks/api", "server.js")
.WithHttpEndpoint(port: 3001, env: "PORT")
.WithExternalHttpEndpoints();
var apiEndpoint = api.GetEndpoint("http");
var svelteApp = builder
.AddViteApp("sveltekit", "./frameworks/sveltekit", runScriptName: "dev")
.PublishAsNodeServer(entryPoint: "build/index.js", outputPath: "build")
.WithEnvironment("API_URL", apiEndpoint)
.WithExternalHttpEndpoints();
builder.Build().Run();

The generated container sets HOST=0.0.0.0 and HOSTNAME=0.0.0.0 so the Node.js server binds to all interfaces and is reachable inside the container network.

Use PublishAsNpmScript for SSR frameworks that start production by running an npm-compatible script, such as Nuxt, Astro SSR, and Remix. Aspire generates a multi-stage Dockerfile that installs production dependencies and uses the package manager script as the container entrypoint.

Choose this method instead of PublishAsNodeServer when the production server imports packages from node_modules at runtime or the framework’s recommended production command is an npm script.

AppHost.cs
#pragma warning disable ASPIREJAVASCRIPT001
var builder = DistributedApplication.CreateBuilder(args);
builder.AddDockerComposeEnvironment("compose");
var api = builder
.AddNodeApp("api", "./frameworks/api", "server.js")
.WithHttpEndpoint(port: 3001, env: "PORT")
.WithExternalHttpEndpoints();
var apiEndpoint = api.GetEndpoint("http");
var nuxtApp = builder
.AddViteApp("nuxt", "./frameworks/nuxt", runScriptName: "dev")
.PublishAsNpmScript(startScriptName: "start")
.WithEnvironment("API_URL", apiEndpoint)
.WithEnvironment("NUXT_API_URL", apiEndpoint)
.WithExternalHttpEndpoints();
builder.Build().Run();

The generated container sets HOST=0.0.0.0 and HOSTNAME=0.0.0.0 so the server binds to all interfaces inside the container network.

Use AddNextJsApp for Next.js apps that use standalone output. It is more specific than AddJavaScriptApp because it configures the Next.js development server port argument and publish-time standalone container shape, and it validates that the Next.js configuration enables output: "standalone".

Choose this method instead of AddJavaScriptApp(...).PublishAsNpmScript(...) for Next.js standalone deployments. Use the more general npm-script publish method for frameworks where the production server should be started through the package manager and the framework does not have a dedicated Aspire resource.

AppHost.cs
#pragma warning disable ASPIREJAVASCRIPT001
var builder = DistributedApplication.CreateBuilder(args);
builder.AddDockerComposeEnvironment("compose");
var api = builder
.AddNodeApp("api", "./frameworks/api", "server.js")
.WithHttpEndpoint(port: 3001, env: "PORT")
.WithExternalHttpEndpoints();
var apiEndpoint = api.GetEndpoint("http");
builder
.AddNextJsApp("nextjs", "./frameworks/nextjs", runScriptName: "dev")
.WithEnvironment("API_URL", apiEndpoint)
.WithExternalHttpEndpoints();
builder.Build().Run();

Set output: "standalone" in your Next.js configuration so the generated Dockerfile has the server files it needs:

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
module.exports = nextConfig;

For setting up AddNextJsApp with its deploy-time output: "standalone" validation, see JavaScript integration — Add Next.js application.

Another common deployment shape is:

  • The frontend is deployed to its own static file host.
  • The backend is deployed to separate compute.
  • The browser calls the backend directly.
flowchart TD
    Frontend["Built frontend files"] --> StaticHost["Static file host"]
    StaticHost -- "downloads assets" --> Browser["Browser"]
    Browser -- "calls API" --> API["Separate backend API"]

This is a natural model for many SPA teams, especially when they already think in terms of “static site + API”. It can work, but it is not the primary Aspire deployment story for AddViteApp and AddJavaScriptApp.

AppHost.cs
var builder = DistributedApplication.CreateBuilder(args);
var api = builder
.AddNodeApp("api", "./api", "src/index.js")
.WithHttpEndpoint(port: 3000, env: "PORT")
.WithExternalHttpEndpoints();
builder
.AddViteApp("frontend", "./frontend")
.WithExternalHttpEndpoints()
.PublishAsDockerFile();
builder.Build().Run();

This shape pushes more work onto the browser/frontend boundary:

  • The browser now talks to a different origin, so you often need to configure CORS.
  • The frontend needs to know the backend URL for each environment.
  • Vite apps usually consume those values at build time, which means the backend URL must be known when the frontend is built or injected through a separate runtime configuration pattern.
  • Local Vite proxy behavior often hides these production concerns until you try to deploy.

The following example looks reasonable, but it is a trap in publish/deploy:

AppHost.cs
var builder = DistributedApplication.CreateBuilder(args);
var api = builder
.AddNodeApp("api", "./api", "src/index.js")
.WithHttpEndpoint(port: 3001, env: "PORT")
.WithExternalHttpEndpoints();
builder
.AddViteApp("frontend", "./frontend")
.WithReference(api)
.WithEnvironment("VITE_API_BASE_URL", api.GetEndpoint("http"))
.PublishAsDockerFile();
builder.Build().Run();
  • Pit 1 - Runtime environment on the Vite resource

    Example: WithEnvironment(...) / withEnvironment(...) on AddViteApp / addViteApp to set VITE_API_BASE_URL.

    Associated failure: Vite usually reads VITE_* values at build time, so the deployed browser app does not learn its backend URL from the Vite resource at runtime.

  • Pit 2 - Switching the same value to a build arg

    Example: WithBuildArg(...) / withBuildArg(...) to set the backend URL during the frontend image build.

    Associated failure: the backend URL is usually not known when the frontend image is being built.

  • Pit 3 - Trying to wire both sides of the relationship

    Example: the frontend needs the backend URL, while the backend also needs the frontend origin for CORS.

    Associated failure: this creates a deployment-time cycle between the frontend and backend. In publish/deploy, the Vite resource is a build resource, not the runtime web server, so it cannot be the place where the browser discovers the backend URL.

Aspire can still orchestrate the frontend build and the backend resource, but this topology is less integrated than the backend-serves-frontend or gateway-serves-frontend shapes. If this is the model you want, plan for explicit runtime configuration and CORS management.

The following table summarizes common framework outputs and the Aspire patterns they usually map to. Treat it as guidance, not an exhaustive list.

FrameworkRecommended methodEntry pointConfiguration required
Vite / React / VuePublishAsStaticWebsiteN/A (YARP serves dist/)None
AngularPublishAsStaticWebsiteN/A (YARP serves dist/)outputPath in angular.json so the build writes to dist/ directly
Astro (static output)PublishAsStaticWebsiteN/A (YARP serves dist/)None
SvelteKitPublishAsNodeServerbuild/index.js@sveltejs/adapter-node
TanStack StartPublishAsNodeServer.output/server/index.mjsNone (Nitro node-server preset by default)
Next.jsAddNextJsAppserver.js (in .next/standalone/)output: "standalone" in next.config.*
NuxtPublishAsNpmScriptnode .output/server/index.mjs (via start)NUXT_ prefix on runtimeConfig env vars
Astro SSRPublishAsNpmScriptnode ./dist/server/entry.mjs (via start)@astrojs/node, prerender: false per page
Remix / React RouterPublishAsNpmScriptreact-router-serve (via start)None
Qwik CityPublishAsNpmScriptnode server/entry.node-server.js (via start)Node server adapter, Node 20+

For per-framework AppHost snippets, see JavaScript integration — Framework examples.

These are issues that aren’t always called out in framework deployment docs but matter for the corresponding publish method to actually work.

The SSR examples below assume the AppHost captures the backend endpoint with api.GetEndpoint("http") / api.getEndpoint('http') and passes it to the framework resource as API_URL. For frameworks with their own runtime-config environment variable conventions, also set the framework-specific variable.

The AppHost snippets in this section assume this shared setup:

AppHost — apphost.ts
import { createBuilder } from './.modules/aspire.js';
const builder = await createBuilder();
await builder.addDockerComposeEnvironment('compose');
const api = await builder
.addNodeApp('api', './frameworks/api', 'server.js')
.withHttpEndpoint({ port: 3001, env: 'PORT' })
.withExternalHttpEndpoints();
const apiEndpoint = await api.getEndpoint('http');
  • Directory structure: Nuxt 4 places pages in app/pages/, not a root pages/ directory.
  • Environment variables: Nuxt maps runtimeConfig keys to env vars with a NUXT_ prefix. To pass the backend URL, set NUXT_API_URL on the resource so Nuxt sees it as runtimeConfig.apiUrl. You can also set API_URL for code that reads process.env.API_URL directly.
  • Server API routes: The recommended pattern for calling external APIs from Nuxt is a server API route (server/api/<name>.ts) that uses useRuntimeConfig(), consumed from a page via useAsyncData.
  • Publish method: Always use PublishAsNpmScript for Nuxt. The Nitro .output/ looks self-contained, but server-side data fetching via useAsyncData / useFetch fails without the full node_modules available at runtime.
AppHost — apphost.ts
await builder
.addViteApp('nuxt', './frameworks/nuxt', { runScriptName: 'dev' })
.publishAsNpmScript({ startScriptName: 'start' })
.withEnvironment('API_URL', apiEndpoint)
.withEnvironment('NUXT_API_URL', apiEndpoint)
.withExternalHttpEndpoints();
Nuxt — nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'node-server',
},
runtimeConfig: {
apiUrl: '', // Overridden by NUXT_API_URL.
},
});
Nuxt — server/api/weather.ts
export default defineEventHandler(async () => {
const config = useRuntimeConfig();
const apiUrl = config.apiUrl;
if (!apiUrl) {
throw new Error('NUXT_API_URL is not configured.');
}
return $fetch(`${apiUrl}/api/weather`);
});
  • Adapter: Use @astrojs/node so Astro produces a Node SSR build.
  • Pre-rendering: Astro pre-renders pages at build time by default, even with the Node adapter. Add export const prerender = false to any page that needs to run at request time.
  • Environment variables: Use process.env.API_URL, not import.meta.env.API_URL. import.meta.env values are resolved at build time and baked into the output.
  • Runtime dependencies: The built entry.mjs imports unbundled @astrojs/* packages, so SSR Astro must use PublishAsNpmScript. The official Docker recipe confirms node_modules must be copied into the runtime image.
AppHost — apphost.ts
await builder
.addViteApp('astro-ssr', './frameworks/astro-ssr', { runScriptName: 'dev' })
.publishAsNpmScript({ startScriptName: 'start' })
.withEnvironment('API_URL', apiEndpoint)
.withExternalHttpEndpoints();
Astro SSR — astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
adapter: node({
mode: 'standalone',
}),
});
Astro SSR — src/pages/index.astro
---
export const prerender = false;
const apiUrl = process.env.API_URL;
if (!apiUrl) {
throw new Error('API_URL is not configured.');
}
const response = await fetch(`${apiUrl}/api/weather`);
const weather = response.ok ? await response.json() : [];
---
<h1>Astro SSR Weather</h1>
<pre>{JSON.stringify(weather, null, 2)}</pre>
  • Adapter: The default @sveltejs/adapter-auto does not produce a deployable Node.js artifact. Install @sveltejs/adapter-node and update svelte.config.js to use it.
  • Server-side data: Use a +page.server.ts load function for server-side fetching. process.env.API_URL is available inside the load function.
  • Output shape: The build/ directory is fully self-contained, so no node_modules are required at runtime. This makes SvelteKit a good fit for PublishAsNodeServer.
AppHost — apphost.ts
await builder
.addViteApp('sveltekit', './frameworks/sveltekit', { runScriptName: 'dev' })
.publishAsNodeServer('build/index.js', { outputPath: 'build' })
.withEnvironment('API_URL', apiEndpoint)
.withExternalHttpEndpoints();
SvelteKit — svelte.config.js
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter(),
},
};
export default config;
SvelteKit — src/routes/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const apiUrl = process.env.API_URL;
if (!apiUrl) {
throw new Error('API_URL is not configured.');
}
const response = await fetch(`${apiUrl}/api/weather`);
return {
weather: response.ok ? await response.json() : [],
};
};
  • Standalone output: Set output: "standalone" in next.config.*. Without this, the build output requires node_modules at runtime and the generated container won’t run. AddNextJsApp validates this configuration at deploy time.
  • Copy shape: The standalone build produces three directories that must be copied separately into the runtime image: .next/standalone/ (server + bundled deps), .next/static/ (client assets), and public/ (static files). AddNextJsApp handles this automatically; see the official with-docker example if you need to do it manually.
  • Server components: Default App Router components are server components. Use async directly in the component body to fetch data; no special loader pattern is needed.
AppHost — apphost.ts
await builder
.addNextJsApp('nextjs', './frameworks/nextjs', { runScriptName: 'dev' })
.withEnvironment('API_URL', apiEndpoint)
.withExternalHttpEndpoints();
Next.js — next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;
Next.js — app/page.tsx
export default async function Home() {
const apiUrl = process.env.API_URL;
if (!apiUrl) {
throw new Error('API_URL is not configured.');
}
const response = await fetch(`${apiUrl}/api/weather`, {
cache: 'no-store',
});
const weather = response.ok ? await response.json() : [];
return <pre>{JSON.stringify(weather, null, 2)}</pre>;
}
  • Nitro preset: Uses Nitro with the node-server preset by default, which produces a self-contained .output/server/index.mjs. This is why TanStack Start works with PublishAsNodeServer out of the box. See TanStack Start hosting for other deployment targets.
  • Server functions: Use createServerFn for server-side data loading from route loaders.
  • Environment variables: process.env.API_URL is available inside server functions at runtime.
AppHost — apphost.ts
await builder
.addViteApp('tanstack-start', './frameworks/tanstack-start', {
runScriptName: 'dev',
})
.publishAsNodeServer('.output/server/index.mjs', { outputPath: '.output' })
.withEnvironment('API_URL', apiEndpoint)
.withExternalHttpEndpoints();
TanStack Start — src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
const fetchWeather = createServerFn({ method: 'GET' }).handler(async () => {
const apiUrl = process.env.API_URL;
if (!apiUrl) {
throw new Error('API_URL is not configured.');
}
const response = await fetch(`${apiUrl}/api/weather`);
return response.json();
});
export const Route = createFileRoute('/')({
loader: () => fetchWeather(),
});
  • Server binary: react-router-serve lives in node_modules; it’s not bundled into the build output. This is why Remix needs PublishAsNpmScript rather than PublishAsNodeServer. See the React Router deployment guide and the node-custom-server template for production server patterns.
  • Port binding: Pass -- --port "$PORT" as runScriptArguments so the server listens on Aspire’s assigned port.
AppHost — apphost.ts
await builder
.addViteApp('remix', './frameworks/remix', { runScriptName: 'dev' })
.publishAsNpmScript({
startScriptName: 'start',
runScriptArguments: '-- --port "$PORT"',
})
.withEnvironment('API_URL', apiEndpoint)
.withExternalHttpEndpoints();
Remix — package.json
{
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js"
}
}
Remix — app/routes/home.tsx
export async function loader() {
const apiUrl = process.env.API_URL;
if (!apiUrl) {
throw new Error('API_URL is not configured.');
}
const response = await fetch(`${apiUrl}/api/weather`);
return {
weather: response.ok ? await response.json() : [],
};
}
  • Node version: Qwik uses Vite 7, which requires Node 20+. Set engines.node in package.json accordingly.
  • Server adapter: Requires the Qwik Node adapter. Add adaptors/node-server/vite.config.ts with nodeServerAdapter() and a corresponding src/entry.node-server.tsx.
  • Build steps: Requires both npm run build.client and npm run build.server. The default npm run build runs both via qwik build.
  • SSR data loading: Use routeLoader$ for server-side data loading. Read the backend URL via process.env['API_URL'].
AppHost — apphost.ts
await builder
.addViteApp('qwik', './frameworks/qwik', { runScriptName: 'dev' })
.publishAsNpmScript({ startScriptName: 'start' })
.withEnvironment('API_URL', apiEndpoint)
.withExternalHttpEndpoints();
Qwik City — package.json
{
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"scripts": {
"build": "qwik build",
"build.client": "vite build",
"build.server": "vite build -c adaptors/node-server/vite.config.ts",
"start": "node server/entry.node-server.js"
}
}
Qwik City — adaptors/node-server/vite.config.ts
import { nodeServerAdapter } from '@builder.io/qwik-city/adapters/node-server/vite';
import { extendConfig } from '@builder.io/qwik-city/vite';
import baseConfig from '../../vite.config';
export default extendConfig(baseConfig, () => ({
build: {
ssr: true,
rollupOptions: {
input: ['src/entry.node-server.tsx', '@qwik-city-plan'],
},
},
plugins: [nodeServerAdapter({ name: 'node-server', ssg: null })],
}));
Qwik City — src/routes/index.tsx
import { routeLoader$ } from '@builder.io/qwik-city';
export const useWeatherData = routeLoader$(async () => {
const apiUrl = process.env['API_URL'];
if (!apiUrl) {
throw new Error('API_URL is not configured.');
}
const response = await fetch(`${apiUrl}/api/weather`);
return response.json();
});
  • Vite-based: Angular 17+ uses Vite internally via @angular/build. AddViteApp works correctly because Aspire injects --port into ng serve.
  • Dev proxy: Angular doesn’t expose vite.config.ts. Use a proxy.conf.js (not .json) that reads process.env.API_HTTP, referenced from angular.json under serve.options.proxyConfig.
  • Output path: Set outputPath in angular.json to { "base": "dist", "browser": "" } so the production build writes directly to dist/ for PublishAsStaticWebsite.
AppHost — apphost.ts
await builder
.addViteApp('angular', './frameworks/angular', { runScriptName: 'dev' })
.publishAsStaticWebsite({ apiPath: '/api', apiTarget: api })
.withExternalHttpEndpoints();
Angular — proxy.conf.js
const target = process.env.API_HTTPS || process.env.API_HTTP;
if (!target) {
throw new Error(
'API endpoint is not configured. Run the app through Aspire.'
);
}
module.exports = {
'/api': {
target,
secure: false,
changeOrigin: true,
},
};
Angular — angular.json
{
"projects": {
"angular": {
"architect": {
"build": {
"options": {
"outputPath": {
"base": "dist",
"browser": ""
}
}
},
"serve": {
"options": {
"proxyConfig": "proxy.conf.js"
}
}
}
}
}
}
  • Preview is not production: Both Vite and the framework docs explicitly state that vite preview is not a production server. Always use PublishAsStaticWebsite.
  • API calls: Use the apiPath / apiTarget options on PublishAsStaticWebsite so the backend is reachable through YARP. Don’t use VITE_* env vars for runtime API URLs; they’re baked at build time.
  • Dev proxy: Add server.proxy in vite.config.ts reading process.env.API_HTTP to forward /api/* to the backend in dev mode.
AppHost — apphost.ts
await builder
.addViteApp('react', './frameworks/react', { runScriptName: 'dev' })
.publishAsStaticWebsite({ apiPath: '/api', apiTarget: api })
.withExternalHttpEndpoints();
Vite / React / Vue — vite.config.ts
import { defineConfig } from 'vite';
const apiTarget = process.env.API_HTTPS || process.env.API_HTTP;
if (!apiTarget) {
throw new Error(
'API endpoint is not configured. Run the app through Aspire.'
);
}
export default defineConfig({
server: {
proxy: {
'/api': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},
});
Vite / React / Vue — fetch weather
export async function loadWeather() {
const response = await fetch('/api/weather');
return response.json();
}

What JavaScript app resources mean in production

Section titled “What JavaScript app resources mean in production”

AddViteApp and AddJavaScriptApp are best thought of as development commands plus publish-output resources until you choose the production serving model:

  • In run mode, they start the configured development command, such as the Vite dev server with HMR.
  • In publish mode, they produce static frontend assets, Node.js server output, or both.
  • Another resource can serve those artifacts in production, or a JavaScript publish method can make the JavaScript resource publish as its own static website or Node.js server.

If a build-only JavaScript resource is not consumed by another resource, publish/deploy validation fails because that resource would not participate in the deployed app. The error looks similar to:

Build-only container resource(s) 'frontend' are not consumed by another resource and won't participate in publish or deploy. Reference them from another resource, for example using 'PublishWithContainerFiles' or 'PublishWithStaticFiles', or suppress this validation for the app by calling 'builder.Pipeline.DisableBuildOnlyContainerValidation()'.

Prefer fixing the app model by choosing one of the deployment shapes in this article. The app-wide DisableBuildOnlyContainerValidation() escape hatch exists for exceptional cases, but it should not be the normal way to deploy JavaScript frontends.

To disable the validation for the whole app, call DisableBuildOnlyContainerValidation on the pipeline:

AppHost.cs
#pragma warning disable ASPIREPIPELINES001
using Aspire.Hosting.Pipelines;
var builder = DistributedApplication.CreateBuilder(args);
// Add resources...
builder.Pipeline.DisableBuildOnlyContainerValidation();
builder.Build().Run();

The DisableBuildOnlyContainerValidation method is marked experimental with the ASPIREPIPELINES001 diagnostic.

AddNextJsApp is different because it represents the Next.js standalone-server publish model directly. Use it when the app is a Next.js app with output: "standalone" rather than modeling the app with AddJavaScriptApp and choosing a generic npm-script publish method.

  • Expecting AddViteApp or AddJavaScriptApp to be the deployed production web server without choosing a publish method.
  • Modeling a Next.js standalone deployment with generic npm-script publishing instead of AddNextJsApp.
  • Exposing the Vite resource instead of the backend, reverse proxy, or JavaScript publish resource that serves production requests.
  • Adding AddViteApp plus .WithReference(...) and assuming that is enough to deploy the frontend.
  • Using .WithEnvironment(...) on AddViteApp to pass the API URL to the deployed SPA.
  • Calling .WithHttpEndpoint() on AddViteApp.
  • Using VITE_* variables for values that must be resolved at runtime in an already-built SPA.
  • Adding a Vite or JavaScript app without choosing a production serving model, which causes a publish/deploy validation error for an unconsumed build-only container.

For runtime configuration guidance, see JavaScript integration.