Amplify 404 on Refresh: Fix React, Vue and Angular Routes
AWS Amplify 404 on refresh is the single-page-app problem: your React, Vue or Angular site can navigate to /orders/42 because the JavaScript draws that screen, but when someone reloads, bookmarks or pastes that address, the browser asks Amplify's server for a file called orders/42, and there is no such file. The fix is a 200 rewrite in Hosting → Rewrites and redirects that serves /index.html for app routes while leaving the address bar alone, using the regular expression Amplify publishes so that real files (.js, .css, images) are never swapped for HTML. The surprise, and the reason half the "fixes" online make things worse: a 301 redirect to the homepage also makes the 404 disappear, and it throws away the very route your staff were trying to open. Same disappearance, opposite outcome. The same bug has a twin on S3 + CloudFront, with its own two fixes, covered below.
Jake runs a phone repair shop. His booking dashboard, built by Ethan, his friend who does the technical side, gives every repair its own address, so staff send each other links like /orders/42. On Monday one employee clicked through the app and saw order 42. Another pasted the same link into a new tab and got a 404. Same link, same dashboard, two results.
Jake: "How can the same address work for one person and not the other? It's the same website."
Ethan: "It is the same website taking two different routes to the same room. One of your staff walked there from the front door. The other tried to come in through a window that does not exist yet."
Jake: "I have no idea what that means."
Ethan: "Good, then we start at the beginning. Did the router lose the page, or did the server never return your app? That one question decides which setting we change, and it is the only question on this page that matters."
If "static site", "CloudFront" and "bucket" are new words, our plain-English page on what CloudFront is covers the hosting half of this story in five minutes. The app half, routers and single-page apps, is explained below at Jake's pace.
Why a page works until you press Refresh
A modern web app like Jake's dashboard is a single-page application. The server sends one HTML file, index.html, plus a bundle of JavaScript. After that, the JavaScript does everything: when you click "Order 42", it changes what is on screen and changes the address in the bar to /orders/42, without asking the server for anything. That part is the router, a piece of your app. The server never hears about /orders/42 at all.
Now press Refresh. The browser forgets everything it had and sends a brand-new request to the server: "give me /orders/42." The server, which hosts static files, looks for a file at that path. There is index.html, there are some .js and .css files, there is no file called orders/42. 404. The route did not vanish from your code; the server was simply never taught that every app address should be answered with the same index.html.
Jake: "So the first employee's browser already had the app open and just changed the picture. The second employee's browser asked the server for a page that only exists inside the app."
Ethan: "That is exactly it. Your browser knows which screen to draw after the app loads. Your host needs to know which document to send before the app loads. Those are two different jobs, and only the second one is broken."
Jake: "And the fix is to tell the server 'whatever they ask for, hand them index.html'?"
Ethan: "Almost. 'Whatever they ask for' is the mistake we are going to avoid. If someone asks for an image and gets index.html, that is a new bug."
One check before you touch hosting: open the app from / and click your way to the nested page. If that fails too, the problem is in the app, its route definitions, its permissions or a JavaScript error, and no hosting rule can create a route your code never defined.
Rewrite, redirect, or 404: the four entries that look alike
In Amplify's redirect editor you will meet several statuses with similar names. Pick by what you want the browser to experience:
| Rule | What the browser sees | Right use |
|---|---|---|
| 200 rewrite | The address stays /orders/42; the response is the content of /index.html | Single-page app route fallback. This is the fix. |
| 301 permanent redirect | The address changes to the target, permanently, and browsers remember it | A URL or domain that genuinely moved |
| 302 temporary redirect | The address changes, for now | A temporary move |
| 404 not found | A not-found status with whatever page you configure | Pages that really do not exist; a branded 404 |
| 404-200 rewrite | A fallback for not-found requests served with 200 handling | Specialized fallback; check the actual response before relying on it |
A 301 from /orders/42 to / is the trap. The 404 vanishes, the dashboard homepage loads, and the employee who wanted order 42 is looking at the wrong screen with a different address in the bar. Worse, browsers cache 301s, so even after you fix it, that employee's laptop keeps jumping to the homepage. A 200 rewrite leaves /orders/42 in the bar, hands over index.html, and the router inside it reads the preserved address and draws order 42. That is the intended behavior of a single-page app, and it is the only one of the five that gives it to you.
🙋 Jake's Reality Check
Jake: "Couldn't I just redirect everybody to the homepage and the complaints stop?"
Ethan: "The complaints about 404s stop. The complaints about 'I clicked the link for order 42 and got the front page' start the same afternoon, and your staff lose every bookmark they have. Preserve the path. That is the whole job."
The exact Amplify rewrite rule
For a normal static single-page app, one rule does it. In the console: Amplify → your app → Hosting → Rewrites and redirects → Manage redirects. Add a rule, or open the JSON editor and paste this:
[
{
"source": "</^[^.]+$|\\.(?!(css|gif|ico|jpg|js|png|txt|svg|woff|woff2|ttf|map|json|webp)$)([^.]+$)/>",
"target": "/index.html",
"status": "200",
"condition": null
}
]
The angle brackets are part of Amplify's syntax for "this source is a regular expression". The expression itself matches two kinds of path: anything with no dot in it (/orders/42, /account/settings), and anything whose extension is not one of the listed asset types. So /orders/42 becomes index.html and /assets/app.js is left alone to succeed or fail on its own.
The console steps, in order:
- Open the Amplify app and the branch that actually serves your visitors; a rule on
stagingfixes nothing onmain. - Hosting → Rewrites and redirects → Manage redirects.
- Read and record the rules already there before you edit: domain moves, API proxies, old fallbacks. You are about to change an ordered list.
- Add the source expression above, target
/index.html, status 200. Keep any more specific rule (an API proxy, say) above this fallback, because rules are applied in order. - Save. Then open a deep link in a fresh private window, or with
curl -I. - Confirm that a real
.js, a.css, a JSON file and an image still come back as themselves, with their own content types.
There is a blunter version, source /<*>, target /index.html, status 200, which matches everything. It also matches every missing image and every API path, so use it only when nothing else lives on the domain. And if your app has routes with dots in them, like /profile/jane.doe, test them: the expression treats "has a dot" as "probably a file", which is a policy, not a law.
Jake: "What does the regular-expression thing actually do? It looks like a cat walked on the keyboard."
Ethan: "It is a bouncer with two rules. No dot in the address? You are a page, come in and get index.html. A dot with one of these fifteen endings, js, css, png and so on? You are a file, go and find your own file. Everything else is treated as a page."
| Request | What it is | What the rule does |
|---|---|---|
/orders/42 | App route | Rewrite to /index.html |
/account/settings | App route | Rewrite to /index.html |
/assets/app.js | JavaScript file | Excluded; served or 404s honestly |
/images/logo.png | Image | Excluded |
/data/report.csv | A download | Not in the list: add csv to the exclusions |
/api/orders | Backend call | Needs its own, earlier rule or a separate origin |
Jake: "Customers upload photos of cracked screens. Some files are called things like repair.photo.jpg."
Ethan: "That is a file, and the rule leaves it alone, which is what you want. If a photo is missing you need to know it is missing. A missing file is information. Sending index.html instead would hide it behind a dashboard that loaded for no reason."
Where the rule lives, and where it does not
If you are hunting through amplify.yml for a redirects section, stop; you are in the wrong file. amplify.yml is the build specification: commands, dependencies, cache, and which folder to publish. customHttp.yml sets HTTP response headers. Neither holds rewrite rules. Rewrites belong to the app's customRules, which you edit in the Hosting console or through the API. A perfectly good rule pasted under the wrong YAML key does nothing and tells you nothing.
aws amplify get-app --app-id d123example --region us-east-1 --query "app.customRules"
aws amplify update-app --app-id d123example --region us-east-1 --custom-rules file://rules.json
update-app replaces the whole list. Read the current rules first, build rules.json as the complete, ordered set you intend, submit it, then read it back. Check the whole rule list before you save, so one new entry does not wipe out redirects your legacy URLs still need. If you manage infrastructure as code, put the rules there and stop editing the console, or the next deployment will quietly undo your afternoon.
Still 404 with the rule in place: the output folder
The rule can be perfect and the site still 404 (or say "access denied") because /index.html is not where Amplify was told to look. The build publishes a folder, and that folder has to contain index.html at its root. Vite projects usually build into dist; some React setups use build; Angular's browser output depends on the project and often sits one folder deeper than you expect.
version: 1
frontend:
phases:
preBuild:
commands:
- npm ci
build:
commands:
- npm run build
artifacts:
baseDirectory: dist
files:
- "**/*"
cache:
paths:
- node_modules/**/*
Open the build's artifact listing and look for index.html at the top. If it is inside another folder, fix baseDirectory. And one trap for people who fix it in the console: an amplify.yml in the repository overrides console build settings, so a fix made in the console can be reverted by the next deployment. Make the change in the file.
Why the exclusions matter here too: if a hashed asset name went stale and /assets/app.3f2a.js is genuinely missing, the browser should be told so. A blanket rewrite would hand it index.html, and the console would fill with MIME-type complaints about HTML where JavaScript was expected, which is a far more confusing bug than the missing file.
React Router
With React Router, BrowserRouter gives you clean paths like /orders/42, which is why the host has to cooperate. The router defines what to draw; the 200 rewrite makes sure the drawing code arrives:
import { BrowserRouter, Routes, Route } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/orders/:id" element={<Order />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
Two details. If the app is served from a subpath rather than the domain root, the router's basename and the asset base path have to match that prefix, and a root-level rewrite to /index.html may not be the right target. And there is an escape hatch: a hash router gives you /#/orders/42, and everything after the hash never reaches the server, so the hosting problem disappears. The price is an uglier address. For an app that already uses clean URLs, fix the hosting.
Vue Router
Vue's HTML5 history mode has the identical issue: router.push() works, reloading the new address fails. The route table below tells Vue what to show; it says nothing to the host:
import { createRouter, createWebHistory } from "vue-router";
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: HomeView },
{ path: "/orders/:id", component: OrderView },
{ path: "/:pathMatch(.*)*", component: NotFoundView }
]
});
export default router;
If the build uses Vite and the site lives under a prefix, check the base setting; a wrong base gives you a different failure, a blank page with asset errors, not the original 404. And test the shapes staff actually use: Jake's team sends links like /orders/42?tab=parts, and the query string should survive the rewrite and reach Vue intact.
Angular
Angular's default location strategy also uses history URLs, so /customers/42 needs the same server fallback, independent of any guard or resolver inside the app:
import { Routes } from "@angular/router";
export const routes: Routes = [
{ path: "", component: HomeComponent },
{ path: "customers/:id", component: CustomerComponent },
{ path: "**", component: NotFoundComponent }
];
Check the base href if you deploy under a prefix, and find where the Angular builder actually wrote index.html; it is often under a browser folder inside dist, and baseDirectory has to point exactly there.
Jake: "One of the pages fails even when I click to it from inside the app. Is that the same bug?"
Ethan: "No. When every route fails after a refresh, the server is suspicious. When only one route fails after the app has loaded, inspect that route and its guard. Keep the two apart or you will rewrite components for a week to fix a hosting rule."
Next.js and server-rendered apps: do not paste the SPA rule
If the site is Next.js with server-side rendering, the catch-all rewrite is the wrong tool. SSR means a server builds the page for each request; dynamic routes, API routes and framework assets are all supposed to be answered by that server, and funnelling every path to a static index.html breaks exactly the thing that makes SSR work. Amplify Hosting detects supported Next.js versions and sets up managed SSR, including its own CloudFront rewrite; if that generated rule is missing after a build, look at the Rewrites and redirects list and consider redeploying rather than hand-writing a static fallback.
The distinction is between a static export (then yes, it is a static SPA and the rule above applies) and a managed SSR deployment (then the framework owns the response, and a 404 after a build points at next.config.js output settings or the artifact directory, which should be the .next output, not a dist folder copied from a Vite project).
The same 404 on S3 + CloudFront: two fixes, one trade-off
If your files sit in an S3 bucket and CloudFront serves the domain, the symptom is the same and the controls are different. Two things make it messier than Amplify. First, an S3 website endpoint can show index.html as its error document, but that does not turn the 404 into a 200; the browser shows your app while the status still says not found, which search engines and monitoring notice. Second, a private bucket behind Origin Access Control answers a missing key with 403, not 404, so any fallback has to handle both numbers.
| Setup | Why deep links fail | The fix |
|---|---|---|
| Amplify static SPA | No object exists for a browser-history route | The 200 rewrite above |
| S3 website endpoint alone | S3 looks for the nested key and returns 404 | Error document shows the app; status stays 404 |
| CloudFront + S3 origin | Missing object; origin says 403 or 404 | Custom error response, or a viewer-request CloudFront Function |
| CloudFront with several origins | A broad fallback swallows API and file errors | Scope the function to the right cache behavior |
| Next.js managed SSR | The server should be answering | Fix the SSR deployment, not a fallback |
Fix one: custom error responses. In the CloudFront console, open the distribution, then the Error pages tab, and create a custom error response: for 403 (and 404 if your origin produces it), response page path /index.html, response code 200. Wait for the distribution to deploy, then request the deep link again. It is quick, configuration only, and blunt: a missing CSS file, a missing favicon, even a genuine permissions 403 all come back as your app shell with a 200. Imagine 50 visits to /orders/42 and 20 requests for a JavaScript file that no longer exists; a blanket 404-to-200 mapping makes all 70 look successful in your logs while 20 of them are broken.
Fix two: a CloudFront Function on viewer-request that rewrites likely app routes to /index.html before CloudFront asks S3 for anything, and leaves everything else alone:
function handler(event) {
var request = event.request;
var uri = request.uri;
// Leave API calls and anything that looks like a file alone.
if (uri.startsWith("/api/") || uri.includes(".")) {
return request;
}
// Rewrite likely client-side routes to the SPA entry.
request.uri = "/index.html";
return request;
}
Create it under CloudFront → Functions, test it, publish it, and associate the published version with the viewer-request event of the right cache behavior. Code sitting in the Functions screen does nothing until it is published and attached. This version is deliberately conservative: it treats any path with a dot as a file. Adjust it if your routes contain dots. It cannot conjure a missing index.html or fix broken asset paths; it only maps an address to a file that should already exist. Our CloudFront 403 from S3 page covers the permissions side if the origin is refusing everything, not just missing routes.
Jake: "Why not just make every error show the homepage? Then nothing is ever broken."
Ethan: "Then nothing is ever visibly broken, which is worse. If an image is missing, the image request must not pretend to be the application. You would lose the one signal that tells you a photo did not upload."
Caches, invalidations and the 301 your browser remembers
You changed the rule and the browser still shows the old failure. Two caches are in your way. CloudFront caches objects and can cache error responses, so a route that failed before the fix may keep failing for the error-caching TTL; check that setting, and check that a Function is actually associated and the distribution has finished deploying. When you need to force it:
aws cloudfront create-invalidation --distribution-id E123EXAMPLE --paths "/index.html" "/orders/*"
The other cache is the browser's, and it is where that 301 from an earlier experiment lives. A private window and curl -I against the real domain tell you what the server says now, as opposed to what someone's laptop remembers. Our CloudFront cache page is the longer version.
Jake: "My staff get different results on their phones and the shop laptops."
Ethan: "A cached wrong answer is still wrong, but it needs a different fix from a rule that was never deployed. Prove what the server says right now, from a clean window, then worry about the devices."
Test it like a new visitor
Clicking to the page from the homepage proves nothing, because that path never sends the nested address to the server. The honest test starts at the deep link:
- Deploy the branch and copy its exact public URL or custom domain.
- In a new private window, paste
https://example.com/orders/42directly. - Press Refresh on that page.
- In the Network tab, the document request should return your HTML with status 200.
- The address bar should still say
/orders/42. If it jumped to/, you have a redirect, not a rewrite. - Check the
.js,.css, image and API requests: right status, right content type. - Repeat with a route that does not exist and a file that does not exist, and confirm each fails the way it should.
curl -I https://example.com/orders/42
curl -I https://example.com/assets/app.js
curl -I https://example.com/definitely-not-a-real-route
Try query strings, trailing slashes, odd capitalization and the characters that appear in your real IDs. If the document returns 200 and the app itself shows its own Not Found screen, the host is now doing its job and the remaining problem is in the router, the auth guard or the base path.
| What you see | Which layer | Next check |
|---|---|---|
| 404 before any app UI | Hosting fallback | Amplify rule or CloudFront mapping |
| 403 on a deep link, private S3 origin | Origin access or missing key | Bucket policy, OAC, 403 handling |
| 200, but the app shows Not Found | The router | Route definitions, guards, basename, build |
| HTML returned where a .js was requested | Overbroad rewrite or missing asset | Exclusions, asset manifest, asset base path |
| Homepage fine, every nested page blank | Asset base path | Console errors, base / base href |
| Only the newest deployment fails | Build artifact | amplify.yml output folder, build logs |
Jake: "Two complaints this morning: one 404 after refresh, one white screen."
Ethan: "Two different bugs that arrived together. The 404 needs the host to return the app. The white screen means the app arrived and its scripts failed, probably a base path. Fix them separately or you will fix neither."
When a real 404 should stay a 404
The fallback keeps your routes alive, and it also means any invented address returns 200 with a page that says "not found": a soft 404. For an internal dashboard like Jake's, acceptable. For public pages you want indexed, consider static generation or server rendering so the status matches whether content exists. Keep API paths out of the fallback entirely: a JSON client that receives HTML fails in ways that look like an outage. And a 403 is often a real access-denied message; mapping all of them to a cheerful 200 hides exactly the information you need when permissions break.
Ethan: "An address that belongs to the app should load the app. An address that belongs to an API, an image or a file should keep its own behavior. That rule will outlive this bug by years."
The right app, the right account, the right domain
Before you conclude a rule "did not work", confirm you edited the app and branch your visitors use. Staging and production are different apps with different rule lists. From the CLI, aws sts get-caller-identity tells you which account you are in, and Amplify commands need the Region spelled out:
aws sts get-caller-identity
aws amplify get-app --app-id d123example --region us-east-1
aws amplify list-branches --app-id d123example --region us-east-1
If the Amplify-provided domain works and your custom domain does not, the traffic may be reaching a different app, or a domain-level redirect rule is intercepting the path. Our custom domain and HTTPS page walks that wiring.
What the two CloudFront fixes cost
Both approaches ride on CloudFront's request and data charges. The Function adds an invocation charge each time it runs: attach it to a cache behavior that receives 100,000 requests a month and that is roughly 100,000 invocations on that behavior. The custom error response adds no such line but replaces errors far more broadly. Rates vary by price class and tier, so use the current CloudFront pricing page; our CloudFront pricing page explains the free tier that covers most small sites entirely. For a modest booking site, Jake does not need a distributed-systems project: the Amplify rule if the app is on Amplify, and a Function only when a CloudFront-backed bucket has routing the error-page fallback handles badly.
If every fix fails, collect this
A screenshot of a 404 is nearly useless. What finds the bug: the exact deep-link URL; whether it fails on the Amplify domain, the custom domain or the CloudFront domain; the document's HTTP status and content type; which requests returned HTML; whether the address bar changed. For Amplify add the current rewrite JSON, the repository's amplify.yml, the artifact folder and the deployment log. For CloudFront add the distribution ID, origin type, the cache behavior involved, the error mapping and the Function version. If the evidence shows a missing index.html, a wrong Angular output folder or a route the framework never defined, the fix is yours; AWS Support can look at service behavior when the evidence points at the service.
Jake's case was never mysterious once two responses were written down: / returned HTML, /orders/42 returned a hosting 404. One 200 rewrite closed exactly that gap, and the order page's code was never touched.
Tracing one request from the address bar to the rule
When it stays confusing, follow a single request and stop at the first place it stops meaning what the app expects. Start with the full URL, host and path; the Amplify hostname and the custom domain may have different rules. Record the document's status and content type: a 404 with a hosting page means your JavaScript never loaded; a 200 with your index.html means hosting is done and the app is the suspect. Check what the app thinks its root is; a build for the domain root loads /assets/main.js, while one built for a prefix may request /orders/assets/main.js after a deep link, which is the "homepage works, nested pages blank" pattern even after the rewrite is right. Check rule order: an API proxy, a domain redirect or an old wildcard above your fallback acts first. On CloudFront, check which cache behavior handles the path; a Function on the default behavior does nothing for a path matched by another behavior. And finish with a request that should not become your app, a missing image with a known extension; if it comes back as HTML with 200, the fallback is too wide.
Jake: "So I need a test I can repeat without understanding any of this."
Ethan: "Two addresses on a sticky note. /orders/42 should become the order screen. /assets/missing.js should stay a missing file. If those two behave differently for the right reasons, the rewrite is doing its job. Check both after every deployment. Thirty seconds."
The exact Amplify and CloudFront refresh questions people search for
Why does AWS Amplify show 404 when I refresh a page?
A single-page app has no real file at a nested route like /orders/42; the browser draws it, the server has never heard of it. On refresh the server is asked for that file and returns 404. Add a 200 rewrite from app routes to /index.html in Hosting, Rewrites and redirects.
How do I fix amplify react router 404 on refresh?
Add the Amplify 200 rewrite to /index.html with the published SPA regular expression, confirm React Router defines the route (BrowserRouter with a matching Route), and check the build artifact contains index.html at its root.
What is the exact Amplify SPA rewrite regex?
Source </^[^.]+$|\.(?!(css|gif|ico|jpg|js|png|txt|svg|woff|woff2|ttf|map|json|webp)$)([^.]+$)/>, target /index.html, status 200. It matches paths without a dot and paths whose extension is not a common asset type.
What is the difference between an Amplify 200 rewrite and a 301 redirect?
A 200 rewrite serves the target's content while the requested address stays in the bar, so the router can read it. A 301 sends the browser to a different address permanently, loses the route, and is cached by browsers.
Can I put Amplify rewrites in amplify.yml?
No. amplify.yml is the build specification. Rewrite and redirect rules live in Hosting, Rewrites and redirects, or in the app's customRules property via aws amplify update-app.
Does customHttp.yml configure Amplify redirects?
No. customHttp.yml sets HTTP response headers only. Rewrites and redirects are configured separately in the Hosting console or the customRules API property.
Why does Vue Router work until the page reloads?
HTML5 history mode changes the address inside the loaded app without asking the server. A reload requests the nested path from the host, which has no file there. The 200 rewrite to /index.html fixes the host side.
How do I fix Angular 404 on page refresh in Amplify?
Add the 200 SPA rewrite to /index.html, check the Angular routes and base href, and point amplify.yml's baseDirectory at the folder where the Angular builder actually wrote index.html, often a browser folder inside dist.
Should Next.js SSR use a catch-all index.html rewrite?
Usually not. Server-rendered routes must be answered by the server, and Amplify's managed SSR generates its own routing. If SSR routes 404, inspect the next.config.js output settings, the .next artifact and the generated rules rather than adding a static fallback.
How do I fix SPA 404 on refresh with S3 and CloudFront?
Either attach a CloudFront Function on viewer-request that rewrites extensionless paths to /index.html, or create custom error responses mapping 403 and 404 to /index.html with status 200. The Function preserves real file errors; the error mapping is quicker but hides them.
Why does CloudFront return 403 instead of 404 for a missing route?
A private S3 bucket behind Origin Access Control answers a missing key with 403 because the caller is not allowed to know whether the key exists. Any fallback on that setup has to handle 403, not just 404.
Will an S3 custom error document return HTTP 200?
No. The S3 website error document changes the HTML shown for an error, but the response status stays 404. For a 200 you need CloudFront in front with a custom error response or a Function.
How do I test a deep link after fixing the rewrite?
Paste the exact nested URL into a new private window and refresh it. The document should return 200, the address bar should not change, assets should load with their own content types, and a deliberately missing file should still fail.
Why is my SPA showing HTML instead of JavaScript?
The fallback is too broad and is serving /index.html for a missing .js file, or the asset path is wrong after the build. Exclude asset extensions in the rule and fix the artifact or asset base path.
Why does Amplify return access denied even with the SPA rule?
The artifact baseDirectory in amplify.yml does not point at the folder that contains index.html, so the target of the rewrite does not exist. Check the deployed artifact listing and the build output folder.
What if the page returns 200 but React still shows Not Found?
Hosting is now correct; the app is the problem. Check the route definitions, the router basename for subpath deployments, authentication guards and JavaScript console errors.
Jake's dashboard got its one rule on Monday afternoon. The sticky note with two addresses is still on the monitor by the counter, and the newest employee checks both after every deployment without knowing, or needing to know, what a regular expression is. Order 42 opens from a pasted link now. A missing photo still says it is missing, which is how Jake found out on Thursday that the upload form had broken, two hours before a customer would have.
📌 If you keep one line from this page
A deep link needs a 200 rewrite that serves index.html and keeps the address, never a 301 that throws the route away.
App routes become the app; files and API paths keep their own behavior. Test both from a private window after every deploy.
Revision note. Written October 9, 2026, with the Amplify rewrite regex and the CloudFront error-response behavior in view. A Tamil proverb warns against stepping into water whose depth you do not know; test one deep link before you call the deploy done.