diff --git a/.changeset/stupid-snails-build.md b/.changeset/stupid-snails-build.md
new file mode 100644
index 000000000000..ce35c9374765
--- /dev/null
+++ b/.changeset/stupid-snails-build.md
@@ -0,0 +1,6 @@
+---
+'@astrojs/node': minor
+'astro': minor
+---
+
+Add support for running middleware on prerendered (static) pages in standalone mode
diff --git a/examples/static-with-middleware/.codesandbox/Dockerfile b/examples/static-with-middleware/.codesandbox/Dockerfile
new file mode 100644
index 000000000000..c3b5c81a121d
--- /dev/null
+++ b/examples/static-with-middleware/.codesandbox/Dockerfile
@@ -0,0 +1 @@
+FROM node:18-bullseye
diff --git a/examples/static-with-middleware/.gitignore b/examples/static-with-middleware/.gitignore
new file mode 100644
index 000000000000..16d54bb13c8a
--- /dev/null
+++ b/examples/static-with-middleware/.gitignore
@@ -0,0 +1,24 @@
+# build output
+dist/
+# generated types
+.astro/
+
+# dependencies
+node_modules/
+
+# logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+
+# environment variables
+.env
+.env.production
+
+# macOS-specific files
+.DS_Store
+
+# jetbrains setting folder
+.idea/
diff --git a/examples/static-with-middleware/.vscode/extensions.json b/examples/static-with-middleware/.vscode/extensions.json
new file mode 100644
index 000000000000..22a15055d638
--- /dev/null
+++ b/examples/static-with-middleware/.vscode/extensions.json
@@ -0,0 +1,4 @@
+{
+ "recommendations": ["astro-build.astro-vscode"],
+ "unwantedRecommendations": []
+}
diff --git a/examples/static-with-middleware/.vscode/launch.json b/examples/static-with-middleware/.vscode/launch.json
new file mode 100644
index 000000000000..d6422097621f
--- /dev/null
+++ b/examples/static-with-middleware/.vscode/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "command": "./node_modules/.bin/astro dev",
+ "name": "Development server",
+ "request": "launch",
+ "type": "node-terminal"
+ }
+ ]
+}
diff --git a/examples/static-with-middleware/README.md b/examples/static-with-middleware/README.md
new file mode 100644
index 000000000000..7386f98d54e3
--- /dev/null
+++ b/examples/static-with-middleware/README.md
@@ -0,0 +1,66 @@
+# Astro Starter Kit: Static Pages with Runtime Middleware
+
+```sh
+npm create astro@latest -- --template static-with-middleware
+```
+
+[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/static-with-middleware)
+[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/static-with-middleware)
+[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/static-with-middleware/devcontainer.json)
+
+> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
+
+This example demonstrates how to use middleware with statically generated pages using the Node adapter's `runMiddlewareOnRequest` option.
+
+Features:
+
+- ✅ 🔐 Authentication on static pages - Protect statically generated pages with cookie-based authentication
+- ✅ 🚀 Best of both worlds - Get the performance of static pages with the security of runtime checks
+- ✅ 🎯 No edge functions required - Runs on your Node.js server, not in a separate edge runtime
+
+## 🚀 Project Structure
+
+Inside of your Astro project, you'll see the following folders and files:
+
+```text
+/
+├── public/
+├── src/
+│ ├── content/
+│ ├── layouts/
+│ ├── pages/
+│ ├── content.config.ts
+│ └── middleware.ts
+├── astro.config.mjs
+├── README.md
+├── package.json
+└── tsconfig.json
+```
+
+This example includes a complete authentication flow:
+
+- **Login page** (`/login`) - Form-based login with demo credentials
+- **Protected pages** (`/dashboard`, `/entries/secret-post`) - Require authentication
+- **Auth API** (`/api/auth/login`, `/api/auth/logout`) - Handle login/logout with cookies
+- **Middleware** (`src/middleware.ts`) - Checks `auth-token` cookie before serving protected pages
+
+The Node adapter automatically skips middleware during prerendering, then runs it at request time when serving static files with full cookie/header access.
+
+## 🧞 Commands
+
+All commands are run from the root of the project, from a terminal:
+
+| Command | Action |
+| :------------------------ | :----------------------------------------------- |
+| `npm install` | Installs dependencies |
+| `npm run dev` | Starts local dev server at `localhost:4321` |
+| `npm run build` | Build your production site to `./dist/` |
+| `npm run preview` | Preview your build locally, before deploying |
+| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
+| `npm run astro -- --help` | Get help using the Astro CLI |
+
+After building, start the server with `node dist/server/entry.mjs` to test the authentication flow.
+
+## 👀 Want to learn more?
+
+Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
diff --git a/examples/static-with-middleware/astro.config.mjs b/examples/static-with-middleware/astro.config.mjs
new file mode 100644
index 000000000000..5acec6890467
--- /dev/null
+++ b/examples/static-with-middleware/astro.config.mjs
@@ -0,0 +1,13 @@
+// @ts-check
+import node from '@astrojs/node';
+import { defineConfig } from 'astro/config';
+
+// https://astro.build/config
+export default defineConfig({
+ output: 'static',
+ adapter: node({
+ mode: 'standalone',
+ runMiddlewareOnRequest: true,
+ }),
+ integrations: [],
+});
diff --git a/examples/static-with-middleware/package.json b/examples/static-with-middleware/package.json
new file mode 100644
index 000000000000..709a69364161
--- /dev/null
+++ b/examples/static-with-middleware/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@example/static-with-middleware",
+ "type": "module",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "astro dev",
+ "build": "astro build",
+ "preview": "astro preview",
+ "astro": "astro",
+ "server": "node dist/server/entry.mjs"
+ },
+ "dependencies": {
+ "@astrojs/node": "^9.4.6",
+ "astro": "^5.14.4"
+ }
+}
\ No newline at end of file
diff --git a/examples/static-with-middleware/public/favicon.svg b/examples/static-with-middleware/public/favicon.svg
new file mode 100644
index 000000000000..f157bd1c5e28
--- /dev/null
+++ b/examples/static-with-middleware/public/favicon.svg
@@ -0,0 +1,9 @@
+
diff --git a/examples/static-with-middleware/src/content.config.ts b/examples/static-with-middleware/src/content.config.ts
new file mode 100644
index 000000000000..438f3abd1065
--- /dev/null
+++ b/examples/static-with-middleware/src/content.config.ts
@@ -0,0 +1,16 @@
+import { defineCollection, z } from 'astro:content';
+import { glob } from 'astro/loaders';
+
+const entries = defineCollection({
+ // Load Markdown and MDX files in the `src/content/entries/` directory.
+ loader: glob({ base: './src/content/entries', pattern: '**/*.{md,mdx}' }),
+ // Type-check frontmatter using a schema
+ schema: () =>
+ z.object({
+ title: z.string(),
+ // Transform string to Date object
+ pubDate: z.coerce.date(),
+ }),
+});
+
+export const collections = { entries };
diff --git a/examples/static-with-middleware/src/content/entries/first-post.md b/examples/static-with-middleware/src/content/entries/first-post.md
new file mode 100644
index 000000000000..97fb3b8b612b
--- /dev/null
+++ b/examples/static-with-middleware/src/content/entries/first-post.md
@@ -0,0 +1,6 @@
+---
+title: 'First post'
+pubDate: 'Jul 08 2022'
+---
+
+This is my first post!
diff --git a/examples/static-with-middleware/src/content/entries/second-post.md b/examples/static-with-middleware/src/content/entries/second-post.md
new file mode 100644
index 000000000000..35f88b6b5ded
--- /dev/null
+++ b/examples/static-with-middleware/src/content/entries/second-post.md
@@ -0,0 +1,6 @@
+---
+title: 'Second post'
+pubDate: 'Jul 15 2022'
+---
+
+Another post has been published.
diff --git a/examples/static-with-middleware/src/content/entries/secret-post.md b/examples/static-with-middleware/src/content/entries/secret-post.md
new file mode 100644
index 000000000000..7bfa2bc55b4c
--- /dev/null
+++ b/examples/static-with-middleware/src/content/entries/secret-post.md
@@ -0,0 +1,6 @@
+---
+title: 'Secret post'
+pubDate: 'Jul 22 2022'
+---
+
+This post is only visible for users that are logged in.
diff --git a/examples/static-with-middleware/src/layouts/Layout.astro b/examples/static-with-middleware/src/layouts/Layout.astro
new file mode 100644
index 000000000000..88d69ccb1da1
--- /dev/null
+++ b/examples/static-with-middleware/src/layouts/Layout.astro
@@ -0,0 +1,127 @@
+---
+const { title } = Astro.props;
+---
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/static-with-middleware/src/middleware.ts b/examples/static-with-middleware/src/middleware.ts
new file mode 100644
index 000000000000..366ceb86397e
--- /dev/null
+++ b/examples/static-with-middleware/src/middleware.ts
@@ -0,0 +1,45 @@
+import { defineMiddleware } from 'astro:middleware';
+
+const AUTH_COOKIE_NAME = 'auth-token';
+const REDIRECT_COOKIE_NAME = 'redirect-after-login';
+
+/**
+ * Middleware runs only during requests, not during build.
+ *
+ * IMPORTANT: While this middleware has access to cookies, headers, and request context,
+ * the Astro page components themselves do NOT have access to this runtime data because
+ * they are prerendered during the build step. This means:
+ * - `Astro.cookies.get()` in page components will always return undefined
+ * - `context.locals` set here won't be accessible in page components
+ * - Only this middleware can read/write cookies and make decisions based on request data
+ *
+ * This is perfect for authentication (redirect to login), but won't work for
+ * personalizing page content based on cookies/headers.
+ */
+export const onRequest = defineMiddleware(async (context, next) => {
+ // Protected paths - require authentication
+ const protectedPaths = ['/secret', '/dashboard'];
+ const isProtected = protectedPaths.some((path) => context.originPathname.includes(path));
+
+ if (!isProtected) {
+ return next();
+ }
+
+ // Dummy auth validation
+ const authToken = context.cookies.get(AUTH_COOKIE_NAME);
+ const isAuthed = authToken?.value === 'valid-token';
+
+ if (!isAuthed) {
+ const response = context.redirect('/login');
+ context.cookies.set(REDIRECT_COOKIE_NAME, context.originPathname, {
+ path: '/',
+ httpOnly: true,
+ maxAge: 60 * 5, // 5 minutes
+ });
+
+ return response;
+ }
+
+ // User is authenticated, allow access to the page
+ return next();
+});
diff --git a/examples/static-with-middleware/src/pages/404.astro b/examples/static-with-middleware/src/pages/404.astro
new file mode 100644
index 000000000000..721f91fbbde4
--- /dev/null
+++ b/examples/static-with-middleware/src/pages/404.astro
@@ -0,0 +1,7 @@
+---
+import Layout from '../layouts/Layout.astro';
+---
+
+
+ Sorry, we couldn't find the page you were looking for.
+
diff --git a/examples/static-with-middleware/src/pages/api/auth/[...all].ts b/examples/static-with-middleware/src/pages/api/auth/[...all].ts
new file mode 100644
index 000000000000..5cebb59dc41b
--- /dev/null
+++ b/examples/static-with-middleware/src/pages/api/auth/[...all].ts
@@ -0,0 +1,47 @@
+import type { APIRoute } from "astro";
+
+export const prerender = false;
+
+// Simple demo auth - in production, use a real auth library
+const DEMO_USERNAME = 'demo';
+const DEMO_PASSWORD = 'password';
+const AUTH_COOKIE_NAME = 'auth-token';
+const REDIRECT_COOKIE_NAME = 'redirect-after-login';
+
+export const POST: APIRoute = async ({ request, cookies, redirect }) => {
+ const url = new URL(request.url);
+ const action = url.pathname.split('/').pop();
+
+ if (action === 'login') {
+ const formData = await request.formData();
+ const username = formData.get('username')?.toString();
+ const password = formData.get('password')?.toString();
+
+ if (username === DEMO_USERNAME && password === DEMO_PASSWORD) {
+ // Set auth cookie (in production, use a secure session token)
+ cookies.set(AUTH_COOKIE_NAME, 'valid-token', {
+ path: '/',
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 60 * 60 * 24, // 24 hours
+ });
+
+ // Redirect to original URL or home
+ const redirectTo = cookies.get(REDIRECT_COOKIE_NAME)?.value || '/';
+ cookies.delete(REDIRECT_COOKIE_NAME, { path: '/' });
+
+ return redirect(redirectTo);
+ }
+
+ // Invalid credentials
+ return redirect('/login?error=invalid');
+ }
+
+ if (action === 'logout') {
+ cookies.delete(AUTH_COOKIE_NAME, { path: '/' });
+ return redirect('/');
+ }
+
+ return new Response('Not found', { status: 404 });
+};
diff --git a/examples/static-with-middleware/src/pages/dashboard.astro b/examples/static-with-middleware/src/pages/dashboard.astro
new file mode 100644
index 000000000000..85561fe4a643
--- /dev/null
+++ b/examples/static-with-middleware/src/pages/dashboard.astro
@@ -0,0 +1,15 @@
+---
+import Layout from '../layouts/Layout.astro';
+
+export const prerender = true; // This is a static page!
+
+---
+
+
+
+
+ This is a protected static page that only authenticated users can access. If you see this,
+ you're successfully signed in!
+
+
+
diff --git a/examples/static-with-middleware/src/pages/entries/[entry].astro b/examples/static-with-middleware/src/pages/entries/[entry].astro
new file mode 100644
index 000000000000..19e81bffd957
--- /dev/null
+++ b/examples/static-with-middleware/src/pages/entries/[entry].astro
@@ -0,0 +1,24 @@
+---
+import { type CollectionEntry, getCollection, getEntry, render } from 'astro:content';
+import Layout from '../../layouts/Layout.astro';
+
+export async function getStaticPaths() {
+ const posts = await getCollection('entries');
+ return posts.map((post) => ({
+ params: { entry: post.id },
+ props: post,
+ }));
+}
+type Props = CollectionEntry<'entries'>;
+
+const { entry } = Astro.params;
+const post = await getEntry('entries', entry);
+if (post === undefined) {
+ return new Response(null, { status: 404 });
+}
+const { Content } = await render(post);
+---
+
+
+
+
diff --git a/examples/static-with-middleware/src/pages/index.astro b/examples/static-with-middleware/src/pages/index.astro
new file mode 100644
index 000000000000..46674b29c488
--- /dev/null
+++ b/examples/static-with-middleware/src/pages/index.astro
@@ -0,0 +1,122 @@
+---
+import { getCollection } from 'astro:content';
+import Layout from '../layouts/Layout.astro';
+
+export const entries = await getCollection('entries');
+---
+
+
+ Static Pages with Middleware Demo
+ This project demonstrates how to protect static pages with runtime middleware using cookies and authentication.
+
+
+ Try the Auth Flow
+
+ - Click on "Protected Dashboard" below (you'll be redirected to login)
+ - Use credentials:
demo / password
+ - You'll be redirected back to the dashboard
+ - The dashboard is a static page protected by middleware!
+
+
+
+
+
+
+
+
+ How It Works
+
+ - Build time: Pages are prerendered as static HTML
+ - Runtime: Middleware runs on Node.js server before serving static files
+ - Cookies: Full access to cookies, headers, and request context
+ - Performance: Assets (CSS/JS/images) skip middleware
+
+
+
+
+
diff --git a/examples/static-with-middleware/src/pages/login.astro b/examples/static-with-middleware/src/pages/login.astro
new file mode 100644
index 000000000000..b49899ddccf6
--- /dev/null
+++ b/examples/static-with-middleware/src/pages/login.astro
@@ -0,0 +1,43 @@
+---
+import Layout from '../layouts/Layout.astro';
+
+const error = Astro.url.searchParams.get('error');
+---
+
+
+
+ You must be logged in to view protected pages.
+
+ {
+ error === 'invalid' && (
+ Invalid username or password. Please try again.
+ )
+ }
+
+
+
Demo Credentials (use these below)
+
+ Username: demo
+ Password: password
+
+
+
+
+
+
diff --git a/examples/static-with-middleware/src/pages/logout.astro b/examples/static-with-middleware/src/pages/logout.astro
new file mode 100644
index 000000000000..04640b0c40bd
--- /dev/null
+++ b/examples/static-with-middleware/src/pages/logout.astro
@@ -0,0 +1,10 @@
+---
+import Layout from '../layouts/Layout.astro';
+---
+
+
+ Use the button below to sign out of your account.
+
+
diff --git a/examples/static-with-middleware/tsconfig.json b/examples/static-with-middleware/tsconfig.json
new file mode 100644
index 000000000000..8bf91d3bb997
--- /dev/null
+++ b/examples/static-with-middleware/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "include": [".astro/types.d.ts", "**/*"],
+ "exclude": ["dist"]
+}
diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts
index 47592ffd5e2e..9cfb7ec526bf 100644
--- a/packages/astro/src/core/app/index.ts
+++ b/packages/astro/src/core/app/index.ts
@@ -6,6 +6,8 @@ import {
import { matchPattern, type RemotePattern } from '../../assets/utils/remotePattern.js';
import { normalizeTheLocale } from '../../i18n/index.js';
import type { RoutesList } from '../../types/astro.js';
+import type { MiddlewareHandler } from '../../types/public/common.js';
+import type { APIContext } from '../../types/public/context.js';
import type { RouteData, SSRManifest } from '../../types/public/internal.js';
import {
clientAddressSymbol,
@@ -18,7 +20,9 @@ import { getSetCookiesFromResponse } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { consoleLogDestination } from '../logger/console.js';
import { AstroIntegrationLogger, Logger } from '../logger/core.js';
+import { createContext } from '../middleware/index.js';
import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js';
+import { sequence } from '../middleware/sequence.js';
import {
appendForwardSlash,
joinPaths,
@@ -142,6 +146,168 @@ export class App {
return this.#manifest.allowedDomains;
}
+ /**
+ * Get the middleware handler from the pipeline.
+ * This includes origin checking and user-defined middleware, but NOT i18n middleware.
+ */
+ async getMiddleware(): Promise {
+ return await this.#pipeline.getMiddleware();
+ }
+
+ /**
+ * Get the complete middleware chain including i18n, origin checking, and user-defined middleware.
+ * This is the same middleware sequence used by RenderContext for SSR routes.
+ * Use this method when you need to execute middleware for prerendered/static routes.
+ */
+ async getAllMiddleware(): Promise {
+ const pipelineMiddleware = await this.#pipeline.getMiddleware();
+ return sequence(...this.#pipeline.internalMiddleware, pipelineMiddleware);
+ }
+
+ /**
+ * Execute middleware for a request without performing a full render.
+ * This is the centralized middleware execution logic used by both SSR and static routes.
+ *
+ * @param request - The incoming request
+ * @param routeData - Optional route data for the matched route
+ * @param middleware - The middleware chain to execute
+ * @param locals - Optional locals object to pass to middleware
+ * @returns Object with:
+ * - handled: true if middleware handled the response (didn't call next())
+ * - response: The response from middleware, or null if next() was called without returning
+ *
+ * **Security Note:** For prerendered routes, the context is marked as `isPrerendered: true`,
+ * which causes the origin checking middleware to skip CSRF validation. This is intentional
+ * to allow static file serving, but you should implement your own security checks in custom
+ * middleware if needed for prerendered pages.
+ */
+ async executeMiddleware(
+ request: Request,
+ routeData: RouteData | undefined,
+ middleware: MiddlewareHandler,
+ locals?: object,
+ ): Promise<{ handled: boolean; response: Response | null }> {
+ // Create the API context with full capabilities
+ const ctx = this.#createAPIContext(request, routeData, locals);
+
+ // Track whether next() was called
+ let nextCalled = false;
+
+ // Create a next function - if called, we'll continue with normal flow
+ const middlewareNext = async (): Promise => {
+ nextCalled = true;
+ // Return a dummy response - the caller will handle continuing
+ return new Response(null);
+ };
+
+ // Execute middleware
+ const response = await middleware(ctx, middlewareNext);
+
+ // If middleware returned a response and didn't call next(), it handled the request
+ if (response && !nextCalled) {
+ // Append cookies from context to response headers
+ for (const setCookieHeaderValue of ctx.cookies.headers()) {
+ response.headers.append('set-cookie', setCookieHeaderValue);
+ }
+
+ return { handled: true, response };
+ }
+
+ // Middleware called next() - continue with normal flow
+ // But return the response so headers can be copied if needed
+ if (response) {
+ // Append cookies from context to response headers
+ for (const setCookieHeaderValue of ctx.cookies.headers()) {
+ response.headers.append('set-cookie', setCookieHeaderValue);
+ }
+ return { handled: false, response };
+ }
+
+ return { handled: false, response: null };
+ }
+
+ /**
+ * Creates a full-featured APIContext for middleware execution.
+ * This provides the same context capabilities as SSR routes, including rewrites, sessions, CSP, etc.
+ * @private
+ */
+ #createAPIContext(
+ request: Request,
+ routeData: RouteData | undefined,
+ locals?: object,
+ ): APIContext {
+ // Extract params from routeData if available
+ const params =
+ routeData && typeof routeData.params === 'object' && !Array.isArray(routeData.params)
+ ? routeData.params
+ : {};
+
+ // Get user-defined locales from manifest
+ const userDefinedLocales = this.#getUserDefinedLocales();
+ const defaultLocale = this.#manifest.i18n?.defaultLocale || '';
+
+ // Create base context using the standard createContext function
+ const baseCtx = createContext({
+ request,
+ params,
+ locals: locals ?? {},
+ defaultLocale,
+ userDefinedLocales,
+ });
+
+ // Enhance with additional properties (site, routePattern, isPrerendered)
+ if (this.#manifest.site) {
+ baseCtx.site = new URL(this.#manifest.site);
+ }
+
+ if (routeData) {
+ baseCtx.routePattern = routeData.route;
+ // Mark as prerendered if this is a static route
+ if (routeData.prerender) {
+ baseCtx.isPrerendered = true;
+ }
+ }
+
+ // Return the enhanced context
+ // Note: For static routes, rewrite() will return a dummy response since we can't actually rewrite
+ // Session access will be undefined for prerendered routes (same as RenderContext behavior)
+ return baseCtx;
+ }
+
+ /**
+ * Get user-defined locales from the manifest in a typed, safe way.
+ * Handles various manifest structures for i18n locales.
+ */
+ #getUserDefinedLocales(): string[] {
+ if (!this.#manifest.i18n?.locales) {
+ return [];
+ }
+
+ const locales = this.#manifest.i18n.locales;
+
+ // Handle array of strings or locale objects
+ if (Array.isArray(locales)) {
+ return locales.filter((l): l is string => typeof l === 'string');
+ }
+
+ // Handle single string
+ if (typeof locales === 'string') {
+ return [locales];
+ }
+
+ // Handle object with codes property
+ if (
+ typeof locales === 'object' &&
+ locales !== null &&
+ 'codes' in locales &&
+ Array.isArray((locales as any).codes)
+ ) {
+ return (locales as any).codes;
+ }
+
+ return [];
+ }
+
protected get manifest(): SSRManifest {
return this.#manifest;
}
diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts
index 36197100f0ff..64ac890e2e94 100644
--- a/packages/astro/src/core/build/generate.ts
+++ b/packages/astro/src/core/build/generate.ts
@@ -76,7 +76,10 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil
const baseDirectory = getServerOutputDirectory(options.settings);
const renderersEntryUrl = new URL('renderers.mjs', baseDirectory);
const renderers = await import(renderersEntryUrl.toString());
- const middleware: MiddlewareHandler = internals.middlewareEntryPoint
+
+ const skipMiddleware = options.settings.adapter?.adapterFeatures?.skipMiddlewareOnPrerender === true;
+
+ const middleware: MiddlewareHandler = internals.middlewareEntryPoint && !skipMiddleware
? await import(internals.middlewareEntryPoint.toString()).then((mod) => mod.onRequest)
: NOOP_MIDDLEWARE_FN;
diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts
index e355d8f41007..79af090c5ae9 100644
--- a/packages/astro/src/core/build/pipeline.ts
+++ b/packages/astro/src/core/build/pipeline.ts
@@ -130,13 +130,16 @@ export class BuildPipeline extends Pipeline {
const renderersEntryUrl = new URL(`renderers.mjs?time=${Date.now()}`, baseDirectory);
const renderers = await import(renderersEntryUrl.toString());
- const middleware = internals.middlewareEntryPoint
- ? async function () {
- // @ts-expect-error: the compiler can't understand the previous check
- const mod = await import(internals.middlewareEntryPoint.toString());
- return { onRequest: mod.onRequest };
- }
- : manifest.middleware;
+ let middleware = undefined;
+ if (!settings.adapter?.adapterFeatures?.skipMiddlewareOnPrerender) {
+ middleware = internals.middlewareEntryPoint
+ ? async function () {
+ // @ts-expect-error: the compiler can't understand the previous check
+ const mod = await import(internals.middlewareEntryPoint.toString());
+ return { onRequest: mod.onRequest };
+ }
+ : manifest.middleware;
+ }
if (!renderers) {
throw new Error(
diff --git a/packages/astro/src/core/request.ts b/packages/astro/src/core/request.ts
index e2c79211e99a..33a77f18c193 100644
--- a/packages/astro/src/core/request.ts
+++ b/packages/astro/src/core/request.ts
@@ -13,12 +13,17 @@ interface CreateRequestOptions {
locals?: object | undefined;
/**
* Whether the request is being created for a static build or for a prerendered page within a hybrid/SSR build, or for emulating one of those in dev mode.
- *
+ * *
+ * @default false
+ */
+ isPrerendered?: boolean;
+ /**
* When `true`, the request will not include search parameters or body, and warn when headers are accessed.
+ * This is the default for prerendered pages unless `skipMiddlewareOnPrerender` is set to `true` in the adapter config.
*
* @default false
*/
- isPrerendered?: boolean;
+ useStaticContext?: boolean;
routePattern: string;
@@ -39,11 +44,12 @@ export function createRequest({
body = undefined,
logger,
isPrerendered = false,
+ useStaticContext = false,
routePattern,
init,
}: CreateRequestOptions): Request {
// headers are made available on the created request only if the request is for a page that will be on-demand rendered
- const headersObj = isPrerendered
+ const headersObj = useStaticContext
? undefined
: headers instanceof Headers
? headers
@@ -57,8 +63,8 @@ export function createRequest({
if (typeof url === 'string') url = new URL(url);
- // Remove search parameters if the request is for a page that will be on-demand rendered
- if (isPrerendered) {
+ // Remove search parameters
+ if (useStaticContext) {
url.search = '';
}
@@ -70,7 +76,7 @@ export function createRequest({
...init,
});
- if (isPrerendered) {
+ if (useStaticContext) {
// Warn when accessing headers in SSG mode
let _headers = request.headers;
diff --git a/packages/astro/src/types/public/integrations.ts b/packages/astro/src/types/public/integrations.ts
index d96c07a77596..1f00d549d2a0 100644
--- a/packages/astro/src/types/public/integrations.ts
+++ b/packages/astro/src/types/public/integrations.ts
@@ -100,6 +100,12 @@ export interface AstroAdapterFeatures {
* is out of experimental
*/
experimentalStaticHeaders?: boolean;
+
+ /**
+ * If enabled by the adapter, middleware will be skipped during the prerendering phase.
+ * This is used by adapters that handle middleware at request time for static pages.
+ */
+ skipMiddlewareOnPrerender?: boolean;
}
/**
diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts
index 142b00cbb6ac..483713973105 100644
--- a/packages/astro/src/vite-plugin-astro-server/route.ts
+++ b/packages/astro/src/vite-plugin-astro-server/route.ts
@@ -176,6 +176,8 @@ export async function handleRoute({
method: incomingRequest.method,
body,
logger,
+ useStaticContext:
+ route.prerender && !pipeline.settings.adapter?.adapterFeatures?.skipMiddlewareOnPrerender,
isPrerendered: route.prerender,
routePattern: route.component,
});
diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts
index 2a2a0e535992..21bfb681ff01 100644
--- a/packages/integrations/node/src/index.ts
+++ b/packages/integrations/node/src/index.ts
@@ -22,6 +22,7 @@ export function getAdapter(options: Options): AstroAdapter {
buildOutput: 'server',
edgeMiddleware: false,
experimentalStaticHeaders: options.experimentalStaticHeaders,
+ skipMiddlewareOnPrerender: options.runMiddlewareOnRequest,
},
supportedAstroFeatures: {
hybridOutput: 'stable',
@@ -94,6 +95,7 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr
'astro:build:generated': ({ experimentalRouteToHeaders }) => {
_routeToHeaders = experimentalRouteToHeaders;
},
+
'astro:config:done': ({ setAdapter, config }) => {
_options = {
...userOptions,
@@ -103,6 +105,7 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr
port: config.server.port,
assets: config.build.assets,
experimentalStaticHeaders: userOptions.experimentalStaticHeaders ?? false,
+ runMiddlewareOnRequest: userOptions.runMiddlewareOnRequest ?? false,
experimentalErrorPageHost,
};
setAdapter(getAdapter(_options));
diff --git a/packages/integrations/node/src/serve-app.ts b/packages/integrations/node/src/serve-app.ts
index 3d22e9c5f334..a62de336bb75 100644
--- a/packages/integrations/node/src/serve-app.ts
+++ b/packages/integrations/node/src/serve-app.ts
@@ -1,5 +1,6 @@
-import { AsyncLocalStorage } from 'node:async_hooks';
import { NodeApp } from 'astro/app/node';
+import { createRequestSafely, handleRequestCreationError } from './serve-utils.js';
+import { requestAls } from './standalone.js';
import type { Options, RequestHandler } from './types.js';
/**
@@ -8,18 +9,6 @@ import type { Options, RequestHandler } from './types.js';
* Intended to be used in both standalone and middleware mode.
*/
export function createAppHandler(app: NodeApp, options: Options): RequestHandler {
- /**
- * Keep track of the current request path using AsyncLocalStorage.
- * Used to log unhandled rejections with a helpful message.
- */
- const als = new AsyncLocalStorage();
- const logger = app.getAdapterLogger();
- process.on('unhandledRejection', (reason) => {
- const requestUrl = als.getStore();
- logger.error(`Unhandled rejection while rendering ${requestUrl}`);
- console.error(reason);
- });
-
const originUrl = options.experimentalErrorPageHost
? new URL(options.experimentalErrorPageHost)
: undefined;
@@ -34,16 +23,10 @@ export function createAppHandler(app: NodeApp, options: Options): RequestHandler
: undefined;
return async (req, res, next, locals) => {
- let request: Request;
- try {
- request = NodeApp.createRequest(req, {
- allowedDomains: app.getAllowedDomains?.() ?? [],
- });
- } catch (err) {
- logger.error(`Could not render ${req.url}`);
- console.error(err);
- res.statusCode = 500;
- res.end('Internal Server Error');
+ // Create Request object with proper error handling
+ const { request, error: requestError } = createRequestSafely(req, app);
+ if (!request) {
+ handleRequestCreationError(req, res, requestError, app);
return;
}
@@ -51,7 +34,7 @@ export function createAppHandler(app: NodeApp, options: Options): RequestHandler
// handle them dynamically, so prerendered routes are included here.
const routeData = app.match(request, true);
if (routeData) {
- const response = await als.run(request.url, () =>
+ const response = await requestAls.run(request.url, () =>
app.render(request, {
addCookieHeader: true,
locals,
@@ -63,7 +46,10 @@ export function createAppHandler(app: NodeApp, options: Options): RequestHandler
} else if (next) {
return next();
} else {
- const response = await app.render(req, { addCookieHeader: true, prerenderedErrorPageFetch });
+ const response = await app.render(request, {
+ addCookieHeader: true,
+ prerenderedErrorPageFetch,
+ });
await NodeApp.writeResponse(response, res);
}
};
diff --git a/packages/integrations/node/src/serve-middleware.ts b/packages/integrations/node/src/serve-middleware.ts
new file mode 100644
index 000000000000..f0c1463a7b6b
--- /dev/null
+++ b/packages/integrations/node/src/serve-middleware.ts
@@ -0,0 +1,30 @@
+import type { RouteData } from 'astro';
+
+/**
+ * Check if a URL path is for a prerendered HTML page (not an asset).
+ * Middleware should only run for HTML pages, not static assets.
+ *
+ * Static assets are identified by:
+ * - Paths starting with `/_astro/` (Astro's built assets directory)
+ * - Files with common asset extensions: css, js, json, xml, txt, ico, png, jpg, jpeg,
+ * gif, svg, woff, woff2, ttf, eot, webp, avif, map
+ *
+ * All other paths are considered HTML pages and will have middleware executed.
+ */
+function isPrerenderedHTMLPage(urlPath: string): boolean {
+ // Middleware should run for HTML pages, not asset files
+ return !urlPath.startsWith('/_astro/') && !/\.(?:css|js|json|xml|txt|ico|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|webp|avif|map)$/i.test(urlPath);
+}
+
+/**
+ * Determine if middleware should be executed for this request.
+ * Only runs when the adapter has opted in via runMiddlewareOnRequest option.
+ */
+export function shouldRunMiddleware(
+ urlPath: string | undefined,
+ _routeData: RouteData | undefined,
+ runMiddlewareOnRequest: boolean,
+): boolean {
+ // Only run middleware for prerendered HTML pages when enabled
+ return !!(urlPath && runMiddlewareOnRequest && isPrerenderedHTMLPage(urlPath));
+}
diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts
index c427e21a7e01..83d885022420 100644
--- a/packages/integrations/node/src/serve-static.ts
+++ b/packages/integrations/node/src/serve-static.ts
@@ -3,8 +3,16 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
import path from 'node:path';
import url from 'node:url';
import { hasFileExtension, isInternalPath } from '@astrojs/internal-helpers/path';
-import type { NodeApp } from 'astro/app/node';
+import { NodeApp } from 'astro/app/node';
import send from 'send';
+import { shouldRunMiddleware } from './serve-middleware.js';
+import {
+ createRequestSafely,
+ handleRequestCreationError,
+ send500Response,
+ setRouteHeaders,
+ tryServe500ErrorPage,
+} from './serve-utils.js';
import type { Options } from './types.js';
/**
@@ -15,104 +23,166 @@ import type { Options } from './types.js';
*/
export function createStaticHandler(app: NodeApp, options: Options) {
const client = resolveClientDir(options);
+
/**
* @param ssr The SSR handler to be called if the static handler does not find a matching file.
+ * @param locals Optional locals object that will be passed to middleware.
*/
- return (req: IncomingMessage, res: ServerResponse, ssr: () => unknown) => {
- if (req.url) {
- const [urlPath, urlQuery] = req.url.split('?');
- const filePath = path.join(client, app.removeBase(urlPath));
+ return async (
+ req: IncomingMessage,
+ res: ServerResponse,
+ ssr: () => unknown,
+ locals?: object,
+ ) => {
+ if (!req.url) {
+ return ssr();
+ }
+
+ const [urlPath, urlQuery] = req.url.split('?');
+
+ // 1. Create Request object safely (early)
+ const { request, error: requestError } = createRequestSafely(req, app);
+ if (!request) {
+ handleRequestCreationError(req, res, requestError, app);
+ return;
+ }
- let isDirectory = false;
+ // 2. Match route once
+ const routeData = app.match(request, true);
+
+ // 3. Check if middleware should run for this request
+ if (shouldRunMiddleware(urlPath, routeData, options.runMiddlewareOnRequest ?? false)) {
try {
- isDirectory = fs.lstatSync(filePath).isDirectory();
- } catch {}
-
- const { trailingSlash = 'ignore' } = options;
-
- const hasSlash = urlPath.endsWith('/');
- let pathname = urlPath;
-
- if (app.headersMap && app.headersMap.length > 0) {
- const routeData = app.match(req, true);
- if (routeData && routeData.prerender) {
- const matchedRoute = app.headersMap.find((header) => header.pathname.includes(pathname));
- if (matchedRoute) {
- for (const header of matchedRoute.headers) {
- res.setHeader(header.key, header.value);
- }
+ // Get middleware (already cached by the pipeline)
+ const middleware = await app.getAllMiddleware();
+
+ // Execute middleware using the core method
+ const result = await app.executeMiddleware(
+ request,
+ routeData,
+ middleware,
+ locals,
+ );
+
+ if (result.handled && result.response) {
+ // Middleware handled the response completely (didn't call next())
+ // Set route headers and write the middleware's response
+ setRouteHeaders(res, app, routeData, urlPath);
+ await NodeApp.writeResponse(result.response, res);
+ return;
+ } else if (result.response) {
+ // Middleware called next() but may have set headers
+ // Apply middleware headers to the response before serving static file
+ for (const [key, value] of result.response.headers.entries()) {
+ res.setHeader(key, value);
}
}
+ // Otherwise, fall through to static file serving
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error(String(err));
+
+ // Try to serve prerendered 500 error page
+ if (tryServe500ErrorPage(req, res, client, app, error)) {
+ return;
+ }
+
+ // No error page available, return generic 500
+ send500Response(req, res, error, app);
+ return;
}
+ }
- switch (trailingSlash) {
- case 'never': {
- if (isDirectory && urlPath !== '/' && hasSlash) {
- pathname = urlPath.slice(0, -1) + (urlQuery ? '?' + urlQuery : '');
- res.statusCode = 301;
- res.setHeader('Location', pathname);
- return res.end();
- }
- if (isDirectory && !hasSlash) {
- pathname = `${urlPath}/index.html`;
- }
- break;
+ // 4. Set headers for static file serving (if not already handled by middleware)
+ setRouteHeaders(res, app, routeData, urlPath);
+
+ // 5. Handle trailing slash and directory logic
+ const filePath = path.join(client, app.removeBase(urlPath));
+ let isDirectory = false;
+ try {
+ isDirectory = fs.lstatSync(filePath).isDirectory();
+ } catch {}
+
+ const { trailingSlash = 'ignore' } = options;
+ const hasSlash = urlPath.endsWith('/');
+ let pathname = urlPath;
+
+ switch (trailingSlash) {
+ case 'never': {
+ if (isDirectory && urlPath !== '/' && hasSlash) {
+ pathname = urlPath.slice(0, -1) + (urlQuery ? '?' + urlQuery : '');
+ res.statusCode = 301;
+ res.setHeader('Location', pathname);
+ return res.end();
}
- case 'ignore': {
- if (isDirectory && !hasSlash) {
- pathname = `${urlPath}/index.html`;
- }
- break;
+ if (isDirectory && !hasSlash) {
+ pathname = `${urlPath}/index.html`;
}
- case 'always': {
- // trailing slash is not added to "subresources"
- // We check if `urlPath` doesn't contain possible internal paths. This should prevent
- // redirects to unwanted paths
- if (!hasSlash && !hasFileExtension(urlPath) && !isInternalPath(urlPath)) {
- pathname = urlPath + '/' + (urlQuery ? '?' + urlQuery : '');
- res.statusCode = 301;
- res.setHeader('Location', pathname);
- return res.end();
- }
- break;
+ break;
+ }
+ case 'ignore': {
+ if (isDirectory && !hasSlash) {
+ pathname = `${urlPath}/index.html`;
}
+ break;
}
- // app.removeBase sometimes returns a path without a leading slash
- pathname = prependForwardSlash(app.removeBase(pathname));
+ case 'always': {
+ // trailing slash is not added to "subresources"
+ // We check if `urlPath` doesn't contain possible internal paths. This should prevent
+ // redirects to unwanted paths
+ if (!hasSlash && !hasFileExtension(urlPath) && !isInternalPath(urlPath)) {
+ pathname = urlPath + '/' + (urlQuery ? '?' + urlQuery : '');
+ res.statusCode = 301;
+ res.setHeader('Location', pathname);
+ return res.end();
+ }
+ break;
+ }
+ }
- const stream = send(req, pathname, {
- root: client,
- dotfiles: pathname.startsWith('/.well-known/') ? 'allow' : 'deny',
- });
+ // 6. Serve the static file
+ // app.removeBase sometimes returns a path without a leading slash
+ pathname = prependForwardSlash(app.removeBase(pathname));
- let forwardError = false;
+ const stream = send(req, pathname, {
+ root: client,
+ dotfiles: pathname.startsWith('/.well-known/') ? 'allow' : 'deny',
+ });
- stream.on('error', (err) => {
- if (forwardError) {
- console.error(err.toString());
- res.writeHead(500);
- res.end('Internal server error');
- return;
- }
- // File not found, forward to the SSR handler
- ssr();
- });
- stream.on('headers', (_res: ServerResponse) => {
- // assets in dist/_astro are hashed and should get the immutable header
- if (pathname.startsWith(`/${options.assets}/`)) {
- // This is the "far future" cache header, used for static files whose name includes their digest hash.
- // 1 year (31,536,000 seconds) is convention.
- // Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable
- _res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
- }
- });
- stream.on('file', () => {
- forwardError = true;
- });
- stream.pipe(res);
- } else {
+ let forwardError = false;
+
+ stream.on('error', (err) => {
+ if (forwardError) {
+ const logger = app.getAdapterLogger();
+ logger.error(`Could not serve static file ${req.url}`);
+ console.error(err);
+ res.statusCode = 500;
+ res.end('Internal Server Error');
+ return;
+ }
+ // File not found
+ // For asset files (when runMiddlewareOnRequest is enabled), return 404 directly
+ // instead of forwarding to SSR to avoid running middleware on missing assets
+ if (options.runMiddlewareOnRequest && !shouldRunMiddleware(urlPath, routeData, true)) {
+ res.statusCode = 404;
+ res.end('Not Found');
+ return;
+ }
+ // Forward to the SSR handler for HTML pages
ssr();
- }
+ });
+ stream.on('headers', (_res: ServerResponse) => {
+ // assets in dist/_astro are hashed and should get the immutable header
+ if (pathname.startsWith(`/${options.assets}/`)) {
+ // This is the "far future" cache header, used for static files whose name includes their digest hash.
+ // 1 year (31,536,000 seconds) is convention.
+ // Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable
+ _res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
+ }
+ });
+ stream.on('file', () => {
+ forwardError = true;
+ });
+ stream.pipe(res);
};
}
@@ -122,7 +192,9 @@ function resolveClientDir(options: Options) {
const rel = path.relative(url.fileURLToPath(serverURLRaw), url.fileURLToPath(clientURLRaw));
// walk up the parent folders until you find the one that is the root of the server entry folder. This is how we find the client folder relatively.
- const serverFolder = path.basename(options.server);
+ // Convert server to string/path in case it's a URL object
+ const serverPath = typeof options.server === 'string' ? options.server : url.fileURLToPath(serverURLRaw);
+ const serverFolder = path.basename(serverPath);
let serverEntryFolderURL = path.dirname(import.meta.url);
while (!serverEntryFolderURL.endsWith(serverFolder)) {
serverEntryFolderURL = path.dirname(serverEntryFolderURL);
@@ -140,3 +212,6 @@ function prependForwardSlash(pth: string) {
function appendForwardSlash(pth: string) {
return pth.endsWith('/') ? pth : pth + '/';
}
+
+
+
diff --git a/packages/integrations/node/src/serve-utils.ts b/packages/integrations/node/src/serve-utils.ts
new file mode 100644
index 000000000000..687eac5d9503
--- /dev/null
+++ b/packages/integrations/node/src/serve-utils.ts
@@ -0,0 +1,112 @@
+import fs from 'node:fs';
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import path from 'node:path';
+import type { RouteData } from 'astro';
+import { NodeApp } from 'astro/app/node';
+import send from 'send';
+
+/**
+ * Safely create a Request object with proper error handling.
+ * Returns null if Request creation fails.
+ */
+export function createRequestSafely(
+ req: IncomingMessage,
+ app: NodeApp,
+): { request: Request; error: null } | { request: null; error: Error } {
+ try {
+ const request = NodeApp.createRequest(req, {
+ allowedDomains: app.getAllowedDomains?.() ?? [],
+ });
+ return { request, error: null };
+ } catch (err) {
+ return { request: null, error: err instanceof Error ? err : new Error(String(err)) };
+ }
+}
+
+/**
+ * Handle errors that occur during Request creation.
+ * Logs the error and sends a 500 response.
+ */
+export function handleRequestCreationError(
+ req: IncomingMessage,
+ res: ServerResponse,
+ error: Error,
+ app: NodeApp,
+): void {
+ const logger = app.getAdapterLogger();
+ logger.error(`Could not render ${req.url}`);
+ console.error(error);
+ res.statusCode = 500;
+ res.end('Internal Server Error');
+}
+
+/**
+ * Attempt to serve a prerendered 500 error page.
+ * Returns true if successful, false otherwise.
+ */
+export function tryServe500ErrorPage(
+ req: IncomingMessage,
+ res: ServerResponse,
+ clientDir: string,
+ app: NodeApp,
+ error: Error,
+): boolean {
+ const logger = app.getAdapterLogger();
+ const errorPagePath = path.join(clientDir, app.removeBase('/500.html'));
+
+ try {
+ if (fs.existsSync(errorPagePath)) {
+ res.statusCode = 500;
+ const errorStream = send(req, app.removeBase('/500.html'), {
+ root: clientDir,
+ });
+ errorStream.pipe(res);
+ logger.error(`Error in middleware for ${req.url}, serving prerendered error page`);
+ console.error(error);
+ return true;
+ }
+ } catch {
+ // Fall through to return false
+ }
+
+ return false;
+}
+
+/**
+ * Send a generic 500 Internal Server Error response.
+ */
+export function send500Response(
+ req: IncomingMessage,
+ res: ServerResponse,
+ error: Error,
+ app: NodeApp,
+): void {
+ const logger = app.getAdapterLogger();
+ logger.error(`Could not render ${req.url}`);
+ console.error(error);
+ res.statusCode = 500;
+ res.end('Internal Server Error');
+}
+
+/**
+ * Set headers for a route from the headersMap.
+ * This applies headers that were defined in the Astro config for specific routes.
+ */
+export function setRouteHeaders(
+ res: ServerResponse,
+ app: NodeApp,
+ routeData: RouteData | undefined,
+ urlPath: string,
+): void {
+ // Only set headers if we have the headersMap and the route is prerendered
+ if (!app.headersMap || app.headersMap.length === 0 || !routeData?.prerender) {
+ return;
+ }
+
+ const matchedRoute = app.headersMap.find((header) => header.pathname.includes(urlPath));
+ if (matchedRoute) {
+ for (const header of matchedRoute.headers) {
+ res.setHeader(header.key, header.value);
+ }
+ }
+}
diff --git a/packages/integrations/node/src/standalone.ts b/packages/integrations/node/src/standalone.ts
index 22587e1a5416..99727b7be3c3 100644
--- a/packages/integrations/node/src/standalone.ts
+++ b/packages/integrations/node/src/standalone.ts
@@ -1,3 +1,4 @@
+import { AsyncLocalStorage } from 'node:async_hooks';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
@@ -9,6 +10,12 @@ import { createAppHandler } from './serve-app.js';
import { createStaticHandler } from './serve-static.js';
import type { Options } from './types.js';
+/**
+ * Shared AsyncLocalStorage for tracking request URLs across static and app handlers.
+ * Used to provide helpful error messages in unhandledRejection logs.
+ */
+export const requestAls = new AsyncLocalStorage();
+
// Used to get Host Value at Runtime
export const hostOptions = (host: Options['host']): string => {
if (typeof host === 'boolean') {
@@ -20,6 +27,20 @@ export const hostOptions = (host: Options['host']): string => {
export default function standalone(app: NodeApp, options: Options) {
const port = process.env.PORT ? Number(process.env.PORT) : (options.port ?? 8080);
const host = process.env.HOST ?? hostOptions(options.host);
+
+ // Set up a single unhandledRejection handler for both static and app handlers
+ const logger = app.getAdapterLogger();
+ const unhandledRejectionHandler = (reason: unknown) => {
+ const requestUrl = requestAls.getStore();
+ if (requestUrl) {
+ logger.error(`Unhandled rejection while processing ${requestUrl}`);
+ } else {
+ logger.error('Unhandled rejection');
+ }
+ console.error(reason);
+ };
+ process.on('unhandledRejection', unhandledRejectionHandler);
+
const handler = createStandaloneHandler(app, options);
const server = createServer(handler, host, port);
server.server.listen(port, host);
diff --git a/packages/integrations/node/src/types.ts b/packages/integrations/node/src/types.ts
index 4a318bdeda29..0c6fa71e053c 100644
--- a/packages/integrations/node/src/types.ts
+++ b/packages/integrations/node/src/types.ts
@@ -30,6 +30,29 @@ export interface UserOptions {
* static files are hosted on a different domain. Do not include a path in the URL: it will be ignored.
*/
experimentalErrorPageHost?: string | URL;
+
+ /**
+ * Run middleware on request, even for prerendered (static) pages.
+ *
+ * If enabled, middleware will execute before serving static HTML files,
+ * allowing access to cookies, headers, and query parameters for authentication,
+ * personalization, and other request-based logic. Middleware will not run during build time.
+ *
+ * **Important limitations:**
+ * - `Astro.cookies` and `context.locals` accessed in page components will NOT reflect
+ * middleware changes, as pages are prerendered during build time. Only middleware
+ * has access to the runtime request context.
+ * - CSRF/origin checking is automatically disabled for prerendered pages to allow
+ * static file serving. Implement your own security checks in middleware if needed.
+ * - Middleware runs on every request to HTML pages, which may impact performance
+ * compared to purely static serving.
+ *
+ * **Note:** This option specifically enables middleware for static/prerendered pages.
+ * For SSR/hybrid pages, middleware runs by default without this option.
+ *
+ * @default false
+ */
+ runMiddlewareOnRequest?: boolean;
}
export interface Options extends UserOptions {
@@ -40,6 +63,7 @@ export interface Options extends UserOptions {
assets: string;
trailingSlash?: SSRManifest['trailingSlash'];
experimentalStaticHeaders: boolean;
+ runMiddlewareOnRequest: boolean;
}
export type RequestHandler = (...args: RequestHandlerParams) => void | Promise;
diff --git a/packages/integrations/node/test/fixtures/middleware-static/astro.config.mjs b/packages/integrations/node/test/fixtures/middleware-static/astro.config.mjs
new file mode 100644
index 000000000000..8b04b7a26c67
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/middleware-static/astro.config.mjs
@@ -0,0 +1,13 @@
+import { defineConfig } from 'astro/config';
+import nodejs from '@astrojs/node';
+
+export default defineConfig({
+ output: 'static',
+ adapter: nodejs({
+ mode: 'standalone',
+ runMiddlewareOnRequest: true,
+ }),
+ security: {
+ checkOrigin: true,
+ },
+});
diff --git a/packages/integrations/node/test/fixtures/middleware-static/package.json b/packages/integrations/node/test/fixtures/middleware-static/package.json
new file mode 100644
index 000000000000..b8f55849dbee
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/middleware-static/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/middleware-static",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "@astrojs/node": "workspace:*",
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/middleware.ts b/packages/integrations/node/test/fixtures/middleware-static/src/middleware.ts
new file mode 100644
index 000000000000..27761ee404c8
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/middleware-static/src/middleware.ts
@@ -0,0 +1,63 @@
+import { defineMiddleware } from 'astro:middleware';
+
+/**
+ * Test middleware for static pages with runMiddlewareOnRequest enabled.
+ *
+ * Note: This middleware has access to runtime request data (cookies, headers, etc.),
+ * but the prerendered page components do NOT. Pages are built at build time and
+ * cannot access runtime request context like Astro.cookies or context.locals.
+ */
+export const onRequest = defineMiddleware(async (context, next) => {
+ const url = new URL(context.request.url);
+
+ // Handle blocked paths BEFORE calling next()
+ if (url.pathname === '/blocked') {
+ return new Response('Access denied', { status: 403 });
+ }
+
+ // Handle redirects BEFORE calling next()
+ if (url.pathname === '/redirect-me') {
+ return context.redirect('/redirected', 302);
+ }
+
+ // Call next() to get the response
+ const response = await next();
+
+ // Add a header to show middleware ran
+ response.headers.set('x-middleware-ran', 'true');
+
+ // Check if locals were set by external middleware (e.g., Express)
+ if (context.locals.expressUser) {
+ response.headers.set('x-express-user', String(context.locals.expressUser));
+ }
+ if (context.locals.expressSessionId) {
+ response.headers.set('x-express-session', String(context.locals.expressSessionId));
+ }
+
+ // Handle cookies
+ const testCookie = context.cookies.get('test-cookie');
+ if (testCookie) {
+ response.headers.set('x-cookie-value', testCookie.value);
+ }
+
+ // Handle user-agent
+ const userAgent = context.request.headers.get('user-agent');
+ if (userAgent === 'test-agent') {
+ response.headers.set('x-user-agent-processed', 'true');
+ }
+
+ // Handle query parameters
+ if (url.searchParams.get('debug') === 'true') {
+ response.headers.set('x-debug-mode', 'true');
+ }
+
+ // Set cookies for specific pages
+ if (url.pathname === '/set-cookie-page') {
+ context.cookies.set('middleware-cookie', 'value', {
+ path: '/',
+ httpOnly: true,
+ });
+ }
+
+ return response;
+});
diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/allowed.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/allowed.astro
new file mode 100644
index 000000000000..1e886266283f
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/allowed.astro
@@ -0,0 +1,8 @@
+
+
+ Allowed
+
+
+ Static Page
+
+
diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/blocked.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/blocked.astro
new file mode 100644
index 000000000000..3d333331eca8
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/blocked.astro
@@ -0,0 +1,8 @@
+
+
+ Blocked
+
+
+ This should not be visible
+
+
diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/error-page.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/error-page.astro
new file mode 100644
index 000000000000..749945479a11
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/error-page.astro
@@ -0,0 +1,8 @@
+
+
+ Error Page
+
+
+ Static Error Page
+
+
diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/redirected.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/redirected.astro
new file mode 100644
index 000000000000..65a830004082
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/redirected.astro
@@ -0,0 +1,8 @@
+
+
+ Redirected
+
+
+ You were redirected here
+
+
diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/set-cookie-page.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/set-cookie-page.astro
new file mode 100644
index 000000000000..df02768290c8
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/set-cookie-page.astro
@@ -0,0 +1,8 @@
+
+
+ Set Cookie
+
+
+ Cookie will be set
+
+
diff --git a/packages/integrations/node/test/fixtures/middleware-static/src/pages/static-page.astro b/packages/integrations/node/test/fixtures/middleware-static/src/pages/static-page.astro
new file mode 100644
index 000000000000..ea068c3e483c
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/middleware-static/src/pages/static-page.astro
@@ -0,0 +1,12 @@
+---
+const cookieValue = Astro.cookies.get('test-cookie')?.value || 'no-cookie';
+---
+
+
+ Static Page
+
+
+ Static Page
+ Cookie: {cookieValue}
+
+
diff --git a/packages/integrations/node/test/middleware-static-pages.test.js b/packages/integrations/node/test/middleware-static-pages.test.js
new file mode 100644
index 000000000000..0fd1b91af423
--- /dev/null
+++ b/packages/integrations/node/test/middleware-static-pages.test.js
@@ -0,0 +1,286 @@
+// Test file for middleware with static pages
+// packages/integrations/node/test/middleware-static-pages.test.js
+
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import nodejs from '../dist/index.js';
+import { loadFixture, waitServerListen } from './test-utils.js';
+
+describe('Middleware for static pages', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let server;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/middleware-static/',
+ output: 'static',
+ adapter: nodejs({
+ mode: 'standalone',
+ runMiddlewareOnRequest: true, // Enable the feature
+ }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ });
+
+ describe('Middleware execution', () => {
+ it('should run middleware for prerendered HTML pages', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/static-page`);
+ // Check for header added by middleware
+ assert.ok(res.headers.get('x-middleware-ran'));
+ assert.equal(res.headers.get('x-middleware-ran'), 'true');
+ });
+
+ it('should have access to cookies in middleware', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/static-page`, {
+ headers: {
+ cookie: 'test-cookie=test-value',
+ },
+ });
+ // Middleware should have read the cookie and added it to response
+ assert.ok(res.headers.get('x-cookie-value'));
+ assert.equal(res.headers.get('x-cookie-value'), 'test-value');
+ });
+
+ it('should have access to headers in middleware', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/static-page`, {
+ headers: {
+ 'user-agent': 'test-agent',
+ },
+ });
+ // Check if middleware processed the header
+ assert.ok(res.headers.get('x-user-agent-processed'));
+ assert.equal(res.headers.get('x-user-agent-processed'), 'true');
+ });
+
+ it('should have access to query parameters', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/static-page?debug=true`);
+ // Middleware should have read query param and added header
+ assert.ok(res.headers.get('x-debug-mode'));
+ assert.equal(res.headers.get('x-debug-mode'), 'true');
+ });
+
+ it('should resolve cookie value to "no-cookie" in prerendered page content', async () => {
+ // Send a request with a cookie
+ const res = await fetch(`http://${server.host}:${server.port}/static-page`, {
+ headers: {
+ cookie: 'test-cookie=should-not-appear',
+ },
+ });
+ assert.equal(res.status, 200);
+ const html = await res.text();
+
+ // The page was prerendered, so it should show "no-cookie" regardless of the cookie sent
+ // This is because Astro.cookies.get() on prerendered pages returns undefined/no-cookie
+ assert.ok(html.includes('Cookie: no-cookie'));
+ // Should NOT include the actual cookie value sent in the request
+ assert.ok(!html.includes('should-not-appear'));
+ });
+ });
+
+ describe('Response handling', () => {
+ it('should use middleware response when middleware returns early', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/blocked`);
+ assert.equal(res.status, 403);
+ const text = await res.text();
+ assert.ok(text.includes('Access denied'));
+ });
+
+ it('should redirect when middleware returns redirect', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/redirect-me`, {
+ redirect: 'manual',
+ });
+ assert.equal(res.status, 302);
+ assert.equal(res.headers.get('location'), '/redirected');
+ });
+
+ it('should serve static file when middleware calls next()', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/allowed`);
+ assert.equal(res.status, 200);
+ const html = await res.text();
+ assert.ok(html.includes('Static Page
'));
+ });
+
+ it('should set cookies from middleware', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/set-cookie-page`);
+ const setCookie = res.headers.get('set-cookie');
+ assert.ok(setCookie);
+ assert.ok(setCookie.includes('middleware-cookie=value'));
+ });
+ });
+
+ describe('Asset handling', () => {
+ it('should NOT run middleware for CSS files', async () => {
+ // Note: This test assumes CSS files exist in _astro folder after build
+ // For a proper test, we'd need to ensure some CSS is generated
+ const res = await fetch(`http://${server.host}:${server.port}/_astro/test.css`);
+ // Middleware should not add this header for assets (404 is ok, just no middleware header)
+ assert.equal(res.headers.get('x-middleware-ran'), null);
+ });
+
+ it('should NOT run middleware for JS files', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/_astro/test.js`);
+ assert.equal(res.headers.get('x-middleware-ran'), null);
+ });
+
+ it('should NOT run middleware for images', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/test.png`);
+ assert.equal(res.headers.get('x-middleware-ran'), null);
+ });
+ });
+
+ describe('Error handling', () => {
+ it('should serve static file even if middleware doesn\'t affect it', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/error-page`);
+ // Should still serve the static file
+ assert.equal(res.status, 200);
+ const html = await res.text();
+ assert.ok(html.includes('Static Error Page
'));
+ });
+
+ it('should handle middleware errors gracefully', async () => {
+ // This test verifies that the server doesn't crash when middleware throws
+ // In practice, middleware errors should be caught and result in 500 responses
+ // For now, we'll just verify the server is still running after potential errors
+ const res = await fetch(`http://${server.host}:${server.port}/static-page`);
+ assert.equal(res.status, 200);
+ // If we got here, the server is still functional
+ assert.ok(true, 'Server handled request successfully');
+ });
+ });
+
+ describe('Locals support', () => {
+ it('should allow middleware to access locals (simulated via manual handler)', async () => {
+ // This test verifies that the handler signature supports locals
+ // In a real Express app, Express middleware would set locals
+ // Note: This is a simple check that the signature is correct
+ // Full integration testing would require Express setup
+
+ const { createStaticHandler } = await import('../dist/serve-static.js');
+ const { handler: appModule } = await fixture.loadAdapterEntryModule();
+ const staticHandler = createStaticHandler(appModule.app, {
+ mode: 'standalone',
+ host: false,
+ port: server.port,
+ server: new URL('./fixtures/middleware-static/dist/server/', import.meta.url),
+ client: new URL('./fixtures/middleware-static/dist/client/', import.meta.url),
+ assets: '_astro',
+ experimentalStaticHeaders: false,
+ runMiddlewareOnRequest: true,
+ });
+
+ // Create mock request/response
+ let responseHeaders = {};
+
+ const mockReq = {
+ url: '/static-page',
+ method: 'GET',
+ headers: {
+ host: 'localhost:' + server.port,
+ },
+ socket: {
+ encrypted: false,
+ },
+ };
+
+ const mockRes = {
+ statusCode: 200,
+ writeHead(status, headers) {
+ this.statusCode = status;
+ if (headers) {
+ Object.assign(responseHeaders, headers);
+ }
+ },
+ setHeader(name, value) {
+ responseHeaders[name] = value;
+ },
+ getHeader(name) {
+ return responseHeaders[name];
+ },
+ end() {
+ // Response ended
+ },
+ headersSent: false,
+ };
+
+ // Simulate Express middleware setting locals
+ const locals = { expressUser: 'testuser', expressSessionId: '12345' };
+
+ // Call the static handler with locals
+ await staticHandler(mockReq, mockRes, () => {}, locals);
+
+ // Wait a bit for async operations
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Verify middleware ran and had access to locals
+ assert.ok(responseHeaders['x-middleware-ran']);
+ assert.equal(responseHeaders['x-express-user'], 'testuser');
+ assert.equal(responseHeaders['x-express-session'], '12345');
+ });
+ });
+
+ describe('Origin checking (CSRF protection)', () => {
+ it('should allow GET requests regardless of origin (safe method)', async () => {
+ // GET is a safe method and should always be allowed
+ const res = await fetch(`http://${server.host}:${server.port}/static-page`, {
+ method: 'GET',
+ headers: {
+ // Simulating a different origin
+ origin: 'https://evil.com',
+ },
+ });
+ assert.equal(res.status, 200);
+ });
+
+ it('should allow HEAD requests regardless of origin (safe method)', async () => {
+ // HEAD is a safe method and should always be allowed
+ const res = await fetch(`http://${server.host}:${server.port}/static-page`, {
+ method: 'HEAD',
+ headers: {
+ origin: 'https://evil.com',
+ },
+ });
+ assert.equal(res.status, 200);
+ });
+
+ it('should allow OPTIONS requests regardless of origin (safe method)', async () => {
+ // OPTIONS is a safe method and should always be allowed
+ const res = await fetch(`http://${server.host}:${server.port}/static-page`, {
+ method: 'OPTIONS',
+ headers: {
+ origin: 'https://evil.com',
+ },
+ });
+ // Should not be blocked (though static pages might return 404 for OPTIONS)
+ assert.notEqual(res.status, 403);
+ });
+
+ it('should skip origin check for prerendered pages (marked as isPrerendered)', async () => {
+ // Origin checking middleware checks context.isPrerendered and skips for prerendered pages
+ // This test verifies that POST requests to static pages are NOT blocked
+ // because they are marked as prerendered
+ const res = await fetch(`http://${server.host}:${server.port}/static-page`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ origin: 'https://evil.com',
+ },
+ body: 'test=data',
+ });
+ // Should NOT be 403 because prerendered pages skip origin checking
+ // (The actual status might be 404 or 405 since POST to static file isn't supported,
+ // but it shouldn't be 403 from CSRF protection)
+ assert.notEqual(res.status, 403, 'Prerendered pages should skip origin checking');
+ });
+ });
+});
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 364b0cd54860..afc629da1092 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -387,6 +387,15 @@ importers:
specifier: ^0.34.3
version: 0.34.4
+ examples/static-with-middleware:
+ dependencies:
+ '@astrojs/node':
+ specifier: ^9.4.6
+ version: link:../../packages/integrations/node
+ astro:
+ specifier: ^5.14.4
+ version: link:../../packages/astro
+
examples/toolbar-app:
devDependencies:
'@types/node':
@@ -5697,6 +5706,15 @@ importers:
specifier: workspace:*
version: link:../../../../../astro
+ packages/integrations/node/test/fixtures/middleware-static:
+ dependencies:
+ '@astrojs/node':
+ specifier: workspace:*
+ version: link:../../..
+ astro:
+ specifier: workspace:*
+ version: link:../../../../../astro
+
packages/integrations/node/test/fixtures/node-middleware:
dependencies:
'@astrojs/node':