fix(web): migrate proxy.ts to edge middleware.ts for Cloudflare compat (#88)

Next.js 16 locks proxy.ts to the Node.js runtime, but OpenNext for
Cloudflare rejects Node.js middleware at build time ("Node.js middleware
is not currently supported", build.js exit 1). Rename to middleware.ts
with an explicit experimental-edge runtime so the Supabase SSR cookie
refresh runs on edge and stays deployable to both Vercel and Workers.

Supabase SSR only uses Web APIs (fetch, cookies), so it is
edge-compatible; the core getUser() refresh logic is unchanged. The
matcher excludes static assets by file extension (not by "contains a
dot") so future dotted dynamic routes (e.g. /u/john.doe) still get the
cookie refresh. getUser() is wrapped in try/catch so a transient
network error (rethrown by @supabase/auth-js) doesn't 500 or crash the
page request — the cookie simply isn't refreshed that round.

Note: runtime must be "experimental-edge", not "edge". Next.js 16 routes
the root middleware file through the pages-router static-info path, where
runtime "edge" throws E1015 at build ("Use runtime 'experimental-edge'
instead"). "experimental-edge" only warns; both are treated as edge by
isEdgeRuntime().

Verified: pnpm typecheck, pnpm build (Vercel), pnpm build:cf
(Cloudflare — Bundling middleware function -> OpenNext build complete,
node-middleware guard no longer fires).
This commit is contained in:
Zonghao Yuan
2026-06-18 11:00:49 +08:00
committed by GitHub
parent 9ec21c46e7
commit 09844bd5db
2 changed files with 64 additions and 31 deletions
+64
View File
@@ -0,0 +1,64 @@
import { type NextRequest, NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";
// Next.js 16 deprecated `middleware` in favor of `proxy`, but `proxy` is locked
// to the Node.js runtime. OpenNext for Cloudflare rejects Node.js middleware at
// build time ("Node.js middleware is not currently supported"), so we keep the
// `middleware` convention with an explicit edge runtime to stay deployable to
// both Vercel and Cloudflare Workers. Revisit once OpenNext supports Node.js
// middleware or `proxy` allows the edge runtime.
export async function middleware(request: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
if (!supabaseUrl || !supabaseKey) return NextResponse.next();
let response = NextResponse.next({ request });
const supabase = createServerClient(supabaseUrl, supabaseKey, {
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
for (const { name, value } of cookiesToSet) {
request.cookies.set(name, value);
}
response = NextResponse.next({ request });
for (const { name, value, options } of cookiesToSet) {
response.cookies.set(name, value, options);
}
},
},
});
// Must await: getUser() triggers the token refresh, and the refreshed
// cookies are written to `response` via the setAll callback above. Returning
// before it resolves can drop the refreshed session cookie.
// getUser() returns auth errors (expired/invalid token) as { error } but
// rethrows non-auth errors (e.g. fetch failures when Supabase is
// unreachable). Swallow those so a transient network blip doesn't 500 or
// crash the whole page request — the cookie simply isn't refreshed this
// round and retries on the next request.
try {
await supabase.auth.getUser();
} catch {
return response;
}
return response;
}
export const config = {
// edge runtime is required for Cloudflare Workers via OpenNext; the Node.js
// middleware path is rejected by its build. Supabase SSR uses only Web APIs
// (fetch, cookies), so it is edge-compatible.
matcher: [
// Match everything except static assets. We exclude by known file
// extensions rather than "path contains a dot" so that future dotted
// dynamic routes (e.g. /u/john.doe) still get the Supabase cookie refresh.
"/((?!_next/static|_next/image|favicon.ico|icon.svg|.*\\.(?:svg|png|jpe?g|gif|webp|avif|ico|css|js|mjs|woff2?|ttf|otf|html|xml|txt|map)).*)",
],
// NOTE: must be "experimental-edge", NOT "edge". Next.js 16 routes the
// root middleware file through the pages-router static-info path, where
// runtime "edge" throws "edge runtime for rendering is currently
// experimental. Use runtime 'experimental-edge' instead." (E1015) at build.
// "experimental-edge" only warns. Both are treated as edge by isEdgeRuntime().
runtime: "experimental-edge",
};
-31
View File
@@ -1,31 +0,0 @@
import { type NextRequest, NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";
export async function proxy(request: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
if (!supabaseUrl || !supabaseKey) return NextResponse.next();
let response = NextResponse.next({ request });
const supabase = createServerClient(supabaseUrl, supabaseKey, {
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
for (const { name, value } of cookiesToSet) {
request.cookies.set(name, value);
}
response = NextResponse.next({ request });
for (const { name, value, options } of cookiesToSet) {
response.cookies.set(name, value, options);
}
},
},
});
// Must await: getUser() triggers the token refresh, and the refreshed
// cookies are written to `response` via the setAll callback above. Returning
// before it resolves can drop the refreshed session cookie.
await supabase.auth.getUser();
return response;
}