Deploy JavaScript apps
이 콘텐츠는 아직 번역되지 않았습니다.
For Vite and other JavaScript frontends, there are three common production deployment models:
- A backend service serves the built frontend files.
- A reverse proxy serves the built frontend files.
- A standalone static frontend talks directly to a separately hosted backend.
All three models use AddViteApp for local development and for producing the frontend build output during publishing. The difference is which resource becomes the production entrypoint and whether the browser talks to the backend directly.
These same production models also apply to AddJavaScriptApp. The difference
is that AddViteApp knows about Vite’s development conventions, while
AddJavaScriptApp makes fewer assumptions and leaves the run and build commands
under your control. 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.
Deployment rule
Section titled “Deployment rule”For deployment, AddViteApp and AddJavaScriptApp should be treated as frontend build resources, not as the final production web server.
To deploy a JavaScript frontend, you must choose which other resource owns the public HTTP surface in production:
- Use
PublishWithContainerFiles(...)when your backend or web server will serve the built frontend files. - Use
PublishWithStaticFiles(...)when your reverse proxy, gateway, or BFF will serve the built frontend files.
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 the built frontend in deployment.
Deployment models
Section titled “Deployment models”| Model | Production entrypoint | Aspire publish API | Best for |
|---|---|---|---|
| Backend serves frontend | API or web server | PublishWithContainerFiles | Apps where one service serves both the API and the frontend |
| Reverse proxy serves frontend | Reverse proxy | PublishWithStaticFiles | Apps that want a gateway or BFF in front of APIs and static frontend assets |
| Static frontend calls backend directly | Static site + separate API | Custom / less integrated | Apps that intentionally keep frontend hosting separate and can manage CORS/config |
Model 1: Backend serves the built frontend
Section titled “Model 1: Backend serves the built frontend”Use this model when your backend, API, or server is responsible for serving static frontend files in production from wwwroot, static, or a similar directory.
This model only works if that backend or server can actually serve the built frontend assets. In other words, the deployed application service must be both the API host and the static file host for the frontend.
flowchart LR
Browser --> App["Node app<br/>serving built frontend files"]
App --> Frontend["Vite build output"]
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();import { createBuilder } from './.modules/aspire.js';
const builder = await createBuilder();
const app = await builder .addNodeApp('app', './api', 'src/index.js') .withHttpEndpoint({ env: 'PORT' }) .withExternalHttpEndpoints();
const frontend = await builder .addViteApp('frontend', './frontend') .withReference(app) .waitFor(app);
await app.publishWithContainerFiles(frontend, './static');
await builder.build().run();How it works
Section titled “How it works”AddViteAppruns the Vite dev server duringaspire run.- During publish, Aspire builds the frontend and extracts its production output.
PublishWithContainerFilescopies those files into the Node app container.- The Node app becomes the deployed HTTP endpoint and serves the frontend files.
Why Aspire supports this model
Section titled “Why Aspire supports this model”This model keeps the production topology simple. The frontend build output becomes part of the same deployable unit as the backend, so one service owns the application surface, static files, and API behavior together.
It also maps well to frameworks that already know how to serve static files from wwwroot, static, or a similar folder. Instead of introducing an extra gateway or frontend-serving container, Aspire lets the backend stay responsible for the final HTTP response.
When to use this model
Section titled “When to use this model”- 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, and static asset hosting.
Implications
Section titled “Implications”- Your backend container gets larger because it now 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.
- This is the default pattern Aspire is steering you toward for Vite frontends unless you intentionally introduce a gateway or BFF to own the public surface.
Model 2: Reverse proxy serves the built frontend
Section titled “Model 2: Reverse proxy serves the built frontend”Use this model when a reverse proxy should be the public entrypoint for your app, either as a gateway or as a backend-for-frontend (BFF).
This model works well when you want a dedicated gateway or BFF in front of the rest of the application. In Aspire, YARP is the built-in example, but the same topology also 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"]
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();import { createBuilder } from './.modules/aspire.js';
const builder = await createBuilder();
const api = await builder .addNodeApp('api', './api', 'src/index.js') .withHttpEndpoint({ env: 'PORT' });const frontend = await builder.addViteApp('frontend', './frontend');
const apiEndpoint = await api.getEndpoint('http');
await builder .addYarp('gateway') .withExternalHttpEndpoints() .publishWithStaticFiles(frontend) .withConfiguration(async (yarp) => { ( await yarp.addRouteFromEndpoint('/api/{**catch-all}', apiEndpoint) ).withTransformPathRemovePrefix('/api'); });
await builder.build().run();Dev-only gateway wiring
Section titled “Dev-only gateway wiring”If your gateway or BFF needs to know about the frontend dev server during local development, gate that wiring to run mode only:
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"));}const frontend = await builder.addViteApp('frontend', './frontend');
const gateway = await builder .addYarp('gateway') .withExternalHttpEndpoints() .publishWithStaticFiles(frontend);
if (await builder.executionContext.isRunMode()) { const frontendDevEndpoint = await frontend.getEndpoint('http'); await gateway.waitFor(frontend); await gateway.withEnvironment('FRONTEND_DEV_URL', frontendDevEndpoint);}How it works
Section titled “How it works”- The reverse proxy owns the public URL surface for both frontend and backend routes.
- API requests such as
/api/*are routed to the backend service. - During publish, Aspire builds the frontend and
PublishWithStaticFilescopies the output into the proxy resource. - In production, the proxy serves frontend routes itself while continuing to proxy API routes.
Why Aspire supports this model
Section titled “Why Aspire supports this model”This model keeps the public entrypoint separate from the application services behind it. The reverse proxy becomes the stable edge for the app, while the frontend build and any backend services remain behind that gateway or BFF.
It is a good fit when you want one place to centralize routing, transforms, headers, and gateway or BFF concerns. YARP is Aspire’s first-class option here, which is why the example uses it, but the architectural tradeoffs are the same for other reverse proxies.
When to use this model
Section titled “When to use this model”- 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.
Implications
Section titled “Implications”- The reverse proxy owns the public endpoint, so 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.
- This is often the better choice when the frontend needs BFF-style behavior or when a standalone Vite build would otherwise need deployment-time configuration from backend resources.
A third model you may be considering
Section titled “A third model you may be considering”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.
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();import { createBuilder } from './.modules/aspire.js';
const builder = await createBuilder();
const api = await builder .addNodeApp('api', './api', 'src/index.js') .withHttpEndpoint({ env: 'PORT' }) .withExternalHttpEndpoints();
await builder .addViteApp('frontend', './frontend') .withExternalHttpEndpoints() .publishAsDockerFile();
await builder.build().run();The following example looks reasonable, but it is a trap in publish/deploy:
var builder = DistributedApplication.CreateBuilder(args);
var api = builder .AddProject<Projects.Api>("api") .WithExternalHttpEndpoints();
builder .AddViteApp("frontend", "./frontend") .WithReference(api) .WithEnvironment("VITE_API_BASE_URL", api.GetEndpoint("https")) .PublishAsDockerFile();
builder.Build().Run();import { createBuilder } from './.modules/aspire.js';
const builder = await createBuilder();
const api = await builder.addProject('api', '../Api/Api.csproj');await api.withExternalHttpEndpoints();
await builder .addViteApp('frontend', './frontend') .withReference(api) .withEnvironment('VITE_API_BASE_URL', api.getEndpoint('https')) .publishAsDockerFile();
await builder.build().run();Pits of failure
Section titled “Pits of failure”-
Pit 1 — Runtime environment on the Vite resource
Example:
WithEnvironment(...)/withEnvironment(...)onAddViteApp/addViteAppto setVITE_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.
Why people fall into this model
Section titled “Why people fall into this model”This model looks familiar if you are used to deploying:
- A Vite or React app to a static site host.
- An API to another host.
- Frontend JavaScript that calls the API directly from the browser.
It can seem like the most obvious path because it keeps the frontend as “just a static site” and avoids adding a backend-served frontend or reverse proxy layer.
Why it gets harder
Section titled “Why it gets harder”This model 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.
What this means in Aspire
Section titled “What this means in Aspire”Aspire can still orchestrate the frontend build and the backend resource, but this topology is less integrated than the two primary models above. In particular, Aspire does not automatically solve:
- Passing the final deployed backend URL into an already-built SPA.
- Browser-to-API cross-origin concerns.
- The split ownership between a standalone static site host and a separate API host.
If this is the model you want, plan for explicit runtime configuration and CORS management. Otherwise, the backend-serves-frontend or reverse-proxy-serves-frontend models are usually easier to reason about in Aspire.
How to choose
Section titled “How to choose”Choose backend serves frontend when the backend already owns the app surface and should also own static file hosting.
Choose reverse proxy serves frontend when you want a gateway or BFF to stay in front of everything and route both frontend and backend traffic.
Choose the standalone static frontend + direct browser-to-backend model only when you intentionally want separate frontend and backend hosting and are willing to manage backend URL configuration and CORS explicitly.
In practice, the decision is usually about who should own the public HTTP surface in production:
- If that should be your backend, use
PublishWithContainerFiles. - If that should be your gateway or BFF, use
PublishWithStaticFiles.
What AddViteApp means in production
Section titled “What AddViteApp means in production”AddViteApp is best thought of as a development server plus a frontend build resource:
- In run mode, it gives you the Vite dev server and HMR.
- In publish mode, it produces frontend build artifacts.
- Another resource serves those artifacts in production.
That distinction is easy to miss if you only read the JavaScript integration page or release notes. This article exists to make the production story explicit.
The important implication is that local Vite behavior does not automatically become production behavior. Routing and proxy setup often have to be expressed twice: once for local development and once for the resource that serves or routes traffic in deployment.
How this also applies to AddJavaScriptApp
Section titled “How this also applies to AddJavaScriptApp”The same production decision applies to AddJavaScriptApp:
- Another resource can serve the built assets.
- Or a gateway/BFF can serve them.
The difference is that AddJavaScriptApp does not assume a particular development server. You choose the run script and the build script, but production still depends on deciding which deployed resource owns the final HTTP surface.
Common mistakes
Section titled “Common mistakes”- Expecting
AddViteAppto be the deployed production web server. - Exposing the Vite resource instead of the backend or reverse proxy resource that serves the built files.
- Adding
AddViteAppplus.WithReference(...)and assuming that is enough to deploy the frontend. - Using
.WithEnvironment(...)onAddViteAppto pass the API URL to the deployed SPA. - Calling
.WithHttpEndpoint()onAddViteApp. - Using
VITE_*variables for values that must be resolved at runtime in an already-built SPA.
For runtime configuration guidance, see JavaScript integration.