diff --git a/apps/ui-server-auth/README.md b/apps/ui-server-auth/README.md
index 3a4d1a026..b65769126 100644
--- a/apps/ui-server-auth/README.md
+++ b/apps/ui-server-auth/README.md
@@ -23,7 +23,7 @@ pnpm -F @proj-airi/ui-server-auth build
## Deployment
-`pnpm -F @proj-airi/ui-server-auth build` writes to `apps/ui-server-auth/dist`. Vue Router owns `/ui/*`, while Vite assets are served from root `/assets/*` so Cloudflare Pages can serve static files without rewriting nested asset paths. Cloudflare Pages uses `public/_redirects` to route `/ui/*` back to the SPA HTML.
+`pnpm -F @proj-airi/ui-server-auth build` writes to `apps/ui-server-auth/dist`. Vue Router owns `/ui/*`, while Vite assets are served from root `/assets/*` so Cloudflare Pages can serve static files without rewriting nested asset paths. `public/_redirects` scopes the SPA rewrite to `/ui/*`, and the top-level `public/404.html` keeps missing assets and other unknown paths as HTTP 404 responses.
The production GitHub Actions workflow deploys this app to the Cloudflare Pages project `moeru-ai-airi-auth` with separate auth-account credentials:
diff --git a/apps/ui-server-auth/public/404.html b/apps/ui-server-auth/public/404.html
new file mode 100644
index 000000000..d63aff67c
--- /dev/null
+++ b/apps/ui-server-auth/public/404.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Page not found
+
+
+
+ Page not found
+
+
+
diff --git a/apps/ui-server-auth/public/_redirects b/apps/ui-server-auth/public/_redirects
index e0706e420..1cb8f20dd 100644
--- a/apps/ui-server-auth/public/_redirects
+++ b/apps/ui-server-auth/public/_redirects
@@ -1,5 +1,7 @@
/ /ui/profile 302
/auth /ui/ 302
/auth/* /ui/:splat 302
-/ui /index.html 200
-/ui/* /index.html 200
+# Pages canonicalizes explicit /index.html requests. Proxy UI routes to the
+# root asset so the browser keeps the original client-side route.
+/ui / 200
+/ui/* / 200
diff --git a/apps/ui-server-auth/src/cloudflare-pages-routing.test.ts b/apps/ui-server-auth/src/cloudflare-pages-routing.test.ts
new file mode 100644
index 000000000..d72b3801f
--- /dev/null
+++ b/apps/ui-server-auth/src/cloudflare-pages-routing.test.ts
@@ -0,0 +1,26 @@
+import { readFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+
+import { describe, expect, it } from 'vitest'
+
+const publicDirectory = resolve(import.meta.dirname, '..', 'public')
+
+describe('cloudflare Pages routing', () => {
+ it('keeps missing static assets as 404 responses while preserving the /ui SPA fallback', async () => {
+ // ROOT CAUSE:
+ //
+ // Without a top-level 404.html, Cloudflare Pages treats every missing file
+ // as an SPA navigation and returns index.html with 200. Missing hashed
+ // assets are then cached as HTML under their immutable asset URLs.
+ //
+ // We keep the SPA rewrite scoped to /ui/* and provide the top-level 404
+ // document that makes every other missing file return HTTP 404.
+ const [notFoundPage, redirects] = await Promise.all([
+ readFile(resolve(publicDirectory, '404.html'), 'utf8'),
+ readFile(resolve(publicDirectory, '_redirects'), 'utf8'),
+ ])
+
+ expect(notFoundPage).toContain('Page not found')
+ expect(redirects).toContain('/ui/* / 200')
+ })
+})