You ran ng build and now someone needs to open it — QA, a client, a stakeholder — before the real deployment gets scheduled. What to upload, what happens to relative asset paths, and the one honest limit: a refresh on an in-app route returns 404 unless you switch to hash routing.
Your ng build finished. Someone now needs to open the result — QA on their own machine, a client on their phone, a stakeholder who is going to click three things and send one comment back — and the actual deployment is two approvals and a change window away.
That gap is a real, ordinary problem, and it does not require a deployment to solve. What it requires is knowing which folder to hand over, and one honest limitation about routes.
The build.
This trips people up often enough that the failure is worth describing precisely, because it is the difference between a clear error and a link that opens to a white screen.
ng build compiles your TypeScript, your templates and your styles into plain files a browser can execute: JavaScript, CSS, an index.html with the right script tags injected, plus whatever you had in your assets folder. That output folder is the whole deliverable. The project folder around it — src/, node_modules/, your config — is build input, and none of it means anything to a browser.
Where that folder is depends on your Angular version, and this is the part worth getting exactly right. ng build writes to dist/<project>/ by default — but under the current @angular/build:application builder that path is only the base. The part a browser can actually serve sits one level down. Angular's own workspace-configuration reference describes that key as "The output directory name for your browser build is within the base output path. This can be safely served to users", and gives its default name as browser.
So before you zip: open dist/<project>/. If there is a browser/ folder inside it, that inner folder is the deliverable — not the one above it. Zipping the wrong level gives you a link that opens to a directory of folders instead of your app. (Both defaults read from Angular's official docs on 2026-09-10; outputPath can be overridden in angular.json, so check yours if the build was customised.)
Upload the project by mistake and you get told, rather than getting a broken link: a ZIP that still contains source files is rejected as an unbuilt project, with the instruction to upload the build folder instead. That behaviour is deliberate — guessing which HTML in a source tree is "the build" is a heuristic, and a wrong guess produces a successful-looking link that opens to nothing, which costs more trust than a plain error does.
Four steps, and the third is the one people skip.
index.html if there is one, and otherwise the shallowest HTML file in the bundle. A normal Angular build gives you exactly one candidate, so this is usually automatic.noindex by default, so the review build does not turn into something a search engine finds.One thing worth knowing because it silently saves you: a single wrapper folder is stripped on the way in. Most "compress this folder" actions produce a ZIP whose entries all begin mybuild/…. Without stripping that, every relative asset path in your index.html would resolve one directory too high and every stylesheet would 404. The stripping only happens when every entry shares the same first segment, which is exactly the wrapper-folder case and nothing else.
If you want the wider picture on bundles rather than the Angular-specific path, the best free way to host a ZIP bundle covers the same ground across hosts.
/dashboard give me a 404?Because that URL looks like a file path and there is no file there.
A router that uses the browser History API rewrites the address bar as the user navigates — /dashboard, /orders/1182 — without ever asking the server for those paths. The first load of the app came from your entry HTML, and everything after that happened in JavaScript. So the URLs are real to the app and imaginary to the host.
Hosts that support this do it with a rewrite rule: any path that does not match a file returns index.html, and the app reads the URL and renders the right view. That rule is not applied here. A request for a path that is not in your bundle returns 404, full stop — the same behaviour whether the missing file is /dashboard or a mistyped image name.
Practically, that means:
| What the reviewer does | What happens |
|---|---|
| Opens the link you sent | Works — that is the entry file |
| Clicks around inside the app | Works — no server request involved |
| Refreshes while on an in-app route | 404 |
| Pastes an in-app URL to a colleague | 404 for the colleague |
So the limitation is narrow and it has a shape: it bites the moment a URL travels on its own.
Depends on who is reviewing and how they work.
Send the entry link when the review is one person opening one link and walking through the flow. Add a line to your message — "start from here, the app handles navigation" — and the 404 case never comes up. This is most reviews.
Switch the review build to hash routing when the URLs are going to get passed around: a QA team filing tickets with reproduction links, a client emailing "look at this screen" to a colleague, anything where someone will copy the address bar. With hash routing every route lives after a #, the browser only ever requests the entry file, and refreshes and pasted links both work.
The current way to turn that on is withHashLocation(), passed to provideRouter alongside your routes:
bootstrapApplication(AppComponent, {
providers: [
provideRouter(appRoutes, withHashLocation())
]
});
withHashLocation comes from @angular/router and is marked stable in Angular's API reference (read 2026-09-10). Keep it to the review build if your production deployment serves real paths — it changes every URL your reviewers see.
That trade — one file that renders from anywhere versus real paths that need a server — is the same one that shows up when you ask an AI tool for a multi-page bundle; prompting for a multi-page site that still ships as one bundle walks the routing half of it in more detail.
If you have a build output folder sitting on your machine right now, zipping it and dropping it takes about as long as reading this section. The No-account path takes a file up to 3 MB with no signup at all; a free account raises that and adds the password and expiry controls below.
Then this is the wrong tool for that half of it, and it is much cheaper to know that now.
A static bundle is files. It can call an API that already exists somewhere reachable from a browser — that works fine, and a review build pointed at a staging API is a completely normal setup. What it cannot do is be the server: no server-side rendering, no API routes of your own, no environment variables, nowhere for a secret to live.
Angular makes that an explicit build setting, which is useful here because it means you can check rather than guess. ng build takes an --output-mode flag (outputMode in angular.json) with two allowed values: Angular's CLI reference defines static as "Generates a static site build artifact for deployment on any static hosting service" and server as "Generates a server application build artifact, required for applications using hybrid rendering or APIs" (read 2026-09-10). A static build is the one that becomes a link. A server build expects Node to be running somewhere.
When the server half is what you need, go and get a platform that provides one. Vercel and Netlify both take a repository and give you server routes, environment variables and a secrets store. That is a genuinely different product from a page host, this one included, and picking it at the start is far cheaper than discovering the need halfway through a review cycle. The wider version of that call is in private link vs public deploy.
All three, once the upload is attached to an account — and they are the reason this shape suits a review build rather than a deployment.
Password — free on every plan, including the free one. Worth it whenever the build shows real data or an unreleased feature. (An upload made with no account has no password or expiry controls at all, because there is no account for those settings to attach to.)
Expiry — free links run on a 7-day clock by default, which happens to match the natural life of a review round. You can set your own date, and on the free tier you can keep one always-on link that never expires.
Replacing the build — upload a new version behind the same URL. The people you sent it to keep the link they already have, which is the difference between "here is build 4" and "here is the review link" as a message you send once.
The per-file ceiling on the free tier is 10 MB, which a normal Angular production bundle sits comfortably under; the full ladder is in when does free HTML hosting stop being free.
This is a way to put a finished build in front of a person. It is not a deployment target, and it should not become one: there is no build pipeline, no branch previews wired to your repo, no rollback history, no custom headers or redirect rules. If you find yourself wanting those, you want the real platform, and the fact that the review link was easy is not an argument for stretching it.
What it is good at is the gap the release process leaves open — the two days between the build is ready and the build is deployed, when somebody still needs to look at it.
Client-side encryption tools turn your page into an encrypted file, so the protection and the artifact are the same object — every edit means re-running the tool and re-uploading. What that actually costs, the salt setting that decides whether your old share links survive, and when a hosted password is the better trade.
Your Slidev or reveal.js deck is already a self-contained web app. Skip the public deploy, the screenshot, and the PDF flattening: run the build, zip the dist folder, and turn it into one private link that keeps every fragment, transition, and code highlight stepping exactly as it did locally.
Replacing a page you have already handed someone is a different problem from publishing it the first time. Why the usual answer is 'learn git', who that answer is actually for, and what the other routes really cost.
miinideck turns a single HTML file into an unguessable link with optional password and expiry. Default-private, never indexed.