While deploying our Astro paywall integration, MonaKiosk, to Mona, we noticed something odd: its middleware was executing twice.
Functionally, it was harmless: our middleware is idempotent, but we didn’t like it, so we began to investigate why.
Background
MonaKiosk is an Astro integration for adding paywalls to Astro sites. It handles:
- Syncing products to polar.sh
- Managing checkout flows
- Enforcing the paywall: Checking if the visitor has paid and serving content accordingly
That last item requires middleware. Since Astro integrations support middleware injection, that’s exactly where we put it.
However, we believe authentication belongs to the host application, not the plugin. So, we designed MonaKiosk with a clear separation of concerns:
- The Integrator (You) handles user authentication.
- MonaKiosk consumes that state to decide which paywall template to show.
To make this work, we expose an isAuthenticated handler in the config so you can pass in the user’s status. A typical setup looks like this:
monaKiosk({
isAuthenticated: (context) => {
return !!context.locals.user;
},
});
For context.locals.user, you can follow the Better-Auth integration guide.
In the application code, developers usually use Astro’s sequence function to ensure their auth logic runs before the paywall check:
// middleware.ts
import { onRequest as monaKioskMiddleware } from "mona-kiosk/middleware";
import { sequence } from "astro:middleware";
export const onRequest = sequence(async (context, next) => {
// ... auth logic here ...
return next();
}, monaKioskMiddleware);
This design felt intuitive and worked perfectly until we spotted duplicate debug logs coming from MonaKiosk.
The Investigation
Pairing with AI helped us trace the issue to how sequence actually works.
It turns out sequence does not change the injection order of middleware. It simply chains handlers together into a single callable function. The real logic sits in Astro’s core:
https://github.com/withastro/astro/blob/main/packages/astro/src/core/middleware/vite-plugin.ts
const code = `
${
userMiddlewareIsPresent
? `import { onRequest as userOnRequest } from '${resolvedMiddlewareId}';`
: ""
}
import { sequence } from 'astro:middleware';
${preMiddleware.importsCode}${postMiddleware.importsCode}
export const onRequest = sequence(
${preMiddleware.sequenceCode}${preMiddleware.sequenceCode ? "," : ""}
${userMiddlewareIsPresent ? `userOnRequest${postMiddleware.sequenceCode ? "," : ""}` : ""}
${postMiddleware.sequenceCode}
);
`.trim();
Here’s what happens:
- Astro collects middleware injected by plugins via
addMiddleware. - It groups them into
preandpostbuckets based on theirorderconfiguration. - It wraps everything in a
sequencecall:pre->user-defined middleware->post.
So, when we manually added monaKioskMiddleware to our sequence chain in middleware.ts, we were running it explicitly. But because the plugin also injected it (likely with a default order), Astro was running it again.
Mystery solved.
A Side Note
When I was pairing with Claude Code, it actually proposed two ways to handle ordering:
- Add an
orderoption to MonaKiosk’s config. - Rely on
sequenceand document that developers should manually sequence it.
I usually prefer keeping plugins lightweight and avoiding extra configuration if there is a workaround. I thought sequence was the manual control. We didn’t realize that addMiddleware would still forcefully inject it independently of our manual sequencing.
The Fix
Once we understood the root cause, the fix was obvious. We updated MonaKiosk to accept a middlewareOrder parameter:
monaKiosk({
isAuthenticated: (context) => {
return !!context.locals.user;
},
middlewareOrder: "post", // Control when the middleware is injected
})
This parameter controls the order option passed to Astro’s addMiddleware. By explicitly setting the order (or allowing it to be disabled if we wanted to rely purely on manual sequencing), we regained control.
With this change, the double execution vanished.