Hosting an Angular Application on Vercel
Deploying Angular to Vercel works whether you're rendering client-side or server-side. SSR requires two things any platform needs: a function entry point and a routing rule to send requests through it. This post covers two Angular-specific gotchas: a build output quirk that silently bypasses server rendering on the root route, and prerendering defaults in Angular 17+ that add cost if you're not paying attention.
TL;DR: CSR deploys work out of the box with a single vercel.json rewrite. SSR needs an api/index.js entry point, two rewrite rules (the root / must be explicit), and includeFiles to bundle the compiled output.
Prerequisites
This post assumes you have an Angular 17+ project (created with ng new) and a Vercel account. If you're adding SSR to an existing project, run ng add @angular/ssr first. Code examples use Node.js 20 and Angular 19.
Static (CSR) deployment
Vercel auto-detects Angular and configures the build for you. For a standard ng new project without SSR, connecting your repo via the Vercel dashboard is all you need to do. Vercel fills in the build command (npm run build) and output directory (dist/<project-name>/browser) automatically, and the Global Network serves the output directly from the CDN.
One thing to sort out before you ship: Angular's router uses pushState navigation, which means URLs like /dashboard only exist client-side. Land on that path directly or hit refresh, and the CDN has nothing at that location. It returns a 404.
vercel.json is how you configure CDN edge routing rules on Vercel's Global Network. Rewrites, redirects, headers, and function routing all run at the edge before a request touches your origin or a serverless function. Drop this at the project root to rewrite all paths back to index.html:
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
Server-side rendering
Angular has supported SSR natively since Angular 17 via @angular/ssr. When you scaffold with ng new my-app --ssr (Angular 17+, Node.js 20+), the build produces two output directories:
dist/
my-app/
browser/ ← static assets
server/
server.mjs ← Node.js SSR handler
Vercel doesn't wire up server.mjs automatically. You need to create an api/index.js that imports Angular's SSR handler, and a vercel.json that routes all requests through it.
Routing requests through a serverless function
Create api/index.js at the project root. This is a Vercel Serverless Function that hands every incoming request off to Angular's SSR handler, which renders the page and returns HTML:
export default async (req, res) => {
const { reqHandler } = await import('../dist/my-app/server/server.mjs');
return reqHandler(req, res);
};
Configuring vercel.json
{
"version": 2,
"rewrites": [
{ "source": "/", "destination": "/api" },
{ "source": "/(.*)", "destination": "/api" }
],
"functions": {
"api/index.js": {
"includeFiles": "dist/my-app/**"
}
}
}
You'll notice two rewrite rules instead of one. Angular's SSR build writes both index.html and index.csr.html to dist/my-app/browser/, and there's an open issue in angular-cli where Vercel's filesystem routing finds index.csr.html and serves it directly for requests to /, before any rewrite rule fires. The result is your root route silently serving the CSR shell with no server rendering, while every other route works fine. The explicit / rewrite forces the root through the serverless function first and prevents the static file lookup from winning.
includeFiles is the other one that catches people out. Without it, Vercel bundles only api/index.js into the serverless function and leaves the compiled Angular output behind. The deployment succeeds without any errors, but the function fails at runtime the moment it tries to import server.mjs.
Match your outputPath exactly
The includeFiles glob is relative to the project root and must match the outputPath configured in angular.json exactly. Check projects > name > architect > build > options > outputPath before deploying. A mismatch produces a successful deployment that silently fails at runtime.
Vercel's serverless function size limit is 250MB uncompressed. For most Angular apps that's comfortable, but large enterprise workspaces with many lazy-loaded modules or a heavy assets/ directory can approach it. If you hit the limit, tighten the includeFiles glob to exclude anything the function doesn't need at runtime. Images and other static assets are served from the CDN; they don't need to be bundled into the function.
The request path with this setup:
Prerendering
Angular 17+ enables prerendering by default in SSR projects. During the build, Angular renders any routes it can resolve statically and writes them as HTML files into dist/my-app/browser/. Vercel finds those files and serves them as static assets from the CDN, so those routes never touch api/index.js.
Prerendering is a good default for routes where every visitor sees the same content: marketing pages, blogs, documentation. The HTML is generated once at build time and served straight from the CDN. Where you need to be deliberate is routes that depend on per-request data. Those should use dynamic SSR, not prerendering. With prerendering on, those routes get a stale snapshot baked at build time, and each CDN cache miss still incurs data transfer cost (fast data transfer, fast origin transfer, and compute), with no indication that per-request rendering is being skipped. Disable prerendering for those routes and let the request flow through api/index.js instead.
To disable prerendering across the whole app, set it in angular.json:
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"prerender": false
}
}
}
}
}
}
If you want prerendering for some routes but not others, the prerender option also accepts a routesFile path listing only the routes to render statically at build time.
Caching and static assets
Angular's production build content-hashes every JavaScript and CSS file by default, producing filenames like main.4f8a23bc.js. When content changes, the filename changes, so browsers and CDNs fetch the new file automatically. You already have cache busting.
Vercel's default Cache-Control for static files is public, max-age=0, must-revalidate. For hashed assets that's more conservative than it needs to be. Since the filename changes with every build, you can tell the browser and CDN to cache those files forever. Configure it in vercel.json alongside your rewrites:
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
],
"headers": [
{
"source": "/(.*)\\.(js|css|woff2|woff|ttf)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/index.html",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=0, must-revalidate"
}
]
}
]
}
immutable tells the browser this URL will never change, so it skips revalidation entirely. A user who loads your app once won't re-download the same JavaScript bundle until you ship a new version with a new hash.
index.html is the one file Angular doesn't hash, and it's what references all the hashed assets. Keep it at max-age=0.
Don't cache index.html aggressively
A long max-age on index.html leaves users stuck with an old shell referencing asset filenames from a previous deploy, filenames that no longer exist on the CDN. Keep it at max-age=0, must-revalidate.
For images and other files in assets/ that Angular doesn't hash, use max-age=86400 (one day) without immutable. If you update a file at the same path, browsers pick up the change within 24 hours.
Environment variables
Angular's environment.ts files compile into the bundle at build time, so they can't change between deployments without a rebuild. For values that need to vary per environment, use Vercel's Environment Variables UI and inject them into your Angular build via process.env or a build-time string replacement in your pipeline.
Client-side env vars are public
Anything embedded in the browser bundle ships to the client in plain text. Anyone can read it in DevTools. Don't put API secrets, private keys, or anything sensitive in environment.ts or any value that gets inlined at build time. If you need to make an authenticated call to a third-party service, proxy it through api/index.js so the secret never reaches the browser.
For server-side code in api/index.js, Vercel injects environment variables at runtime. They're available as process.env.MY_VAR with nothing extra to configure.
Preview deployments
Every branch and pull request gets a unique preview URL running the full SSR path: serverless function, vercel.json routing, all of it. You can verify server rendering behavior before merging.
To check that SSR is actually running, look at the response headers. A server-rendered Angular page includes x-powered-by: Express (or whatever adapter you've configured). A CSR fallback won't have it.
If you want a working starting point, Vercel has an Angular template you can deploy with one click. The source is worth a look — it shows the api/index.js and vercel.json pattern from this post wired into a complete project.
Further reading
- Angular SSR documentation — official guide covering
@angular/ssrsetup, hydration, and route-level render modes - Vercel
vercel.jsonreference — full reference for rewrites, headers, and function configuration - Vercel Serverless Functions — size limits, runtime options, and bundling behavior
- Angular CLI
ng add @angular/ssr— how to add SSR to an existing project without scaffolding from scratch - Vercel Angular template — working starter with
api/index.jsandvercel.jsonalready wired up
Thanks for reading. I'm Charlie, a Solutions Architect at Vercel. Opinions are my own.