diff --git a/.changeset/fix-ssr-assets-manifest-middleware.md b/.changeset/fix-ssr-assets-manifest-middleware.md
new file mode 100644
index 000000000000..b1693e9b4f16
--- /dev/null
+++ b/.changeset/fix-ssr-assets-manifest-middleware.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Fixes CSS, fonts, and other assets failing to load when using `@astrojs/node` in middleware mode with a catch-all route. Previously these assets were incorrectly matched by the catch-all instead of being served as static files.
diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts
index 932bdf0dde03..7cc031504580 100644
--- a/packages/astro/src/core/build/plugins/plugin-manifest.ts
+++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts
@@ -124,6 +124,17 @@ async function createManifest(
internals.staticFiles.add(file);
}
+ // Also include SSR-emitted assets (CSS, fonts, images) tracked in ssrAssetsPerEnvironment.
+ // These assets are moved to the client directory by ssrMoveAssets() later in the pipeline,
+ // so they haven't landed on disk yet when we glob above. Without this, adapters in middleware
+ // mode won't recognize them as static files and will match them against catch-all routes instead.
+ // See: https://github.com/withastro/astro/issues/16039
+ for (const [, ssrAssets] of internals.ssrAssetsPerEnvironment) {
+ for (const asset of ssrAssets) {
+ internals.staticFiles.add(asset);
+ }
+ }
+
const staticFiles = internals.staticFiles;
const encodedKey = await encodeKey(await buildOpts.key);
const manifest = await buildManifest(buildOpts, internals, Array.from(staticFiles), encodedKey);
diff --git a/packages/integrations/node/test/fixtures/ssr-assets-middleware/package.json b/packages/integrations/node/test/fixtures/ssr-assets-middleware/package.json
new file mode 100644
index 000000000000..c6a6e3fd73d7
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/ssr-assets-middleware/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/ssr-assets-middleware",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "@astrojs/node": "workspace:*",
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/ssr-assets-middleware/src/pages/[...path].astro b/packages/integrations/node/test/fixtures/ssr-assets-middleware/src/pages/[...path].astro
new file mode 100644
index 000000000000..a3c4011d36e7
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/ssr-assets-middleware/src/pages/[...path].astro
@@ -0,0 +1,12 @@
+---
+// Catch-all route — simulates a custom 404 handler.
+// This is intentional: if SSR-emitted assets are missing from manifest.assets,
+// asset requests like /_astro/*.css will match this catch-all instead of being
+// served as static files. See https://github.com/withastro/astro/issues/16039
+const { path } = Astro.params;
+---
+
+
+
Not Found
+catch-all: {path}
+
diff --git a/packages/integrations/node/test/fixtures/ssr-assets-middleware/src/pages/index.astro b/packages/integrations/node/test/fixtures/ssr-assets-middleware/src/pages/index.astro
new file mode 100644
index 000000000000..85ab19afc3bc
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/ssr-assets-middleware/src/pages/index.astro
@@ -0,0 +1,7 @@
+---
+import '../styles/main.css';
+---
+
+home
+
home
+
diff --git a/packages/integrations/node/test/fixtures/ssr-assets-middleware/src/styles/main.css b/packages/integrations/node/test/fixtures/ssr-assets-middleware/src/styles/main.css
new file mode 100644
index 000000000000..8963f2c5131a
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/ssr-assets-middleware/src/styles/main.css
@@ -0,0 +1,3 @@
+.container {
+ color: red;
+}
diff --git a/packages/integrations/node/test/node-middleware.test.js b/packages/integrations/node/test/node-middleware.test.js
index 2004a1a088d8..e1603e34b93e 100644
--- a/packages/integrations/node/test/node-middleware.test.js
+++ b/packages/integrations/node/test/node-middleware.test.js
@@ -219,3 +219,67 @@ describe('behavior from middleware, middleware with fastify', () => {
assert.equal(body.text().includes('bar'), true);
});
});
+
+// Regression test for https://github.com/withastro/astro/issues/16039
+// SSR-emitted assets (CSS, fonts, images) must appear in manifest.assets so that
+// the Node adapter in middleware mode can identify them as static files and NOT
+// match them against catch-all routes.
+describe('middleware with fastify and catch-all route: SSR assets in manifest', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let server;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/ssr-assets-middleware/',
+ output: 'server',
+ adapter: nodejs({ mode: 'middleware' }),
+ vite: {
+ build: {
+ // Prevent CSS/SVG from being inlined so they appear as separate
+ // files in dist/client/_astro/ and are tracked in ssrAssetsPerEnvironment.
+ assetsInlineLimit: 0,
+ },
+ },
+ });
+ await fixture.build();
+ const { handler } = await fixture.loadAdapterEntryModule();
+ const app = Fastify({ logger: false });
+ await app
+ .register(fastifyStatic, {
+ root: fileURLToPath(
+ new URL('./fixtures/ssr-assets-middleware/dist/client', import.meta.url),
+ ),
+ })
+ .register(fastifyMiddie);
+ app.use(handler);
+
+ await app.listen({ port: 8890 });
+
+ server = app;
+ });
+
+ after(async () => {
+ server.close();
+ await fixture.clean();
+ });
+
+ it('should serve SSR-emitted CSS assets directly, not the catch-all page', async () => {
+ // First get the index page to find the CSS asset URL
+ const indexRes = await fetch('http://localhost:8890/');
+ assert.equal(indexRes.status, 200);
+ const html = await indexRes.text();
+ const $ = cheerio.load(html);
+
+ // Find the CSS link tag injected by Astro
+ const cssHref = $('link[rel="stylesheet"]').attr('href');
+ assert.ok(cssHref, 'Expected a CSS tag in the page');
+ assert.match(cssHref, /\/_astro\/.*\.css$/);
+
+ // Request the CSS asset — it must be served as CSS, not as the catch-all HTML page
+ const cssRes = await fetch(`http://localhost:8890${cssHref}`);
+ assert.equal(cssRes.status, 200);
+ const contentType = cssRes.headers.get('content-type');
+ assert.ok(contentType?.includes('text/css'), `Expected text/css, got: ${contentType}`);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8cb78df5a47a..42ddb57a3c51 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6078,6 +6078,15 @@ importers:
specifier: workspace:*
version: link:../../../../../astro
+ packages/integrations/node/test/fixtures/ssr-assets-middleware:
+ dependencies:
+ '@astrojs/node':
+ specifier: workspace:*
+ version: link:../../..
+ astro:
+ specifier: workspace:*
+ version: link:../../../../../astro
+
packages/integrations/node/test/fixtures/static-headers:
dependencies:
'@astrojs/node':