Hello there,
I’m developing an application that has a Vercel frontend deployment and a Node.js backend running on Hetzner.
Problem
During the initial design iterations, I’ve made the decision to try to harden the backend access by using a BFF (Backend-for-Frontend) that runs on Vercel. The idea is that the requests that the frontend needs to do to the backend would be done to a function that would inject an extra API_KEY that basically eliminates all other access to the backend unless it is made from that BFF.
The idea was to have DDoS protection and rate limiting out of the box using Vercel’s proxying infrastructure (I’m not 100% the assumption would hold for functions, but I’m pretty sure it would hold for the regular frontend parts), instead of complicating things with extra services like Cloudflare etc.
Generally, it works well, but occasionally I get Vercel NOT_FOUND errors which aren’t very clear on where they are coming from. I’ve managed to get rid of them for a while by disabling caching altogether on those routes, but today they’ve reappeared and this is last ditch effort to find a resolution before completely removing this from the codebase.
Current Behavior
- When the
NOT_FOUNDerror is shown, no actual request makes it to the backend. - There’s no actual request to the
BFFfunction either. - I also cannot find the request ID that I see on the error in the logs.
- In short, there is zero information to work with.
I tried contacting Vercel support at some point about this but they’ve rolled over the responsibility to me and did not help me or figure it out.
Implementation Details
The Vercel function definition which sits in the /api/[…path].ts file:
export const config = {
runtime: 'edge'
}
export default async function handler(req: Request) {
try {
const url = new URL(req.url);
const backendUrl = process.env.BACKEND_URL || 'http://localhost:3000';
// Extract path from URL (remove /api prefix)
const pathString = url.pathname.replace(/^\/api\//, '');
const targetUrl = `${backendUrl}/api/${pathString}${url.search}`;
console.log(
`[BFF] Incoming request: ${req.method} /${pathString}${url.search}`
);
console.log(`[BFF] Target URL: ${targetUrl}`);
console.log(`[BFF] Request headers:`, {
contentType: req.headers.get('content-type'),
hasAuth: !!req.headers.get('authorization'),
hasCookie: !!req.headers.get('cookie'),
});
const headers = new Headers({
'Content-Type': req.headers.get('content-type') || 'application/json',
});
// Inject API key for backend protection
if (process.env.API_KEY) {
headers.set('X-API-Key', process.env.API_KEY);
console.log(`[BFF] Injecting API key header`);
} else {
console.log(`[BFF] No API_KEY configured`);
}
// Forward auth headers from client
const authHeader = req.headers.get('authorization');
if (authHeader) {
headers.set('Authorization', authHeader);
console.log(`[BFF] Forwarding authorization header`);
}
// Forward cookies from client
const cookieHeader = req.headers.get('cookie');
if (cookieHeader) {
headers.set('Cookie', cookieHeader);
console.log(`[BFF] Forwarding cookies`);
}
console.log(`[BFF] Sending request to backend...`);
let body: BodyInit | undefined;
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
body = await req.text();
}
const response = await fetch(targetUrl, {
method: req.method,
headers,
body,
redirect: 'manual',
});
tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite-env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
tsconfig.node.json
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}
vercel.json:
{
"buildCommand": "cd ../.. && pnpm build --filter=web",
"outputDirectory": "dist",
"installCommand": "cd ../.. && pnpm install",
"framework": "vite",
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "no-store, no-cache, must-revalidate, max-age=0" },
{ "key": "CDN-Cache-Control", "value": "no-store" },
{ "key": "Vercel-CDN-Cache-Control", "value": "no-store" }
]
}
],
"rewrites": [
{
"source": "/((?!api/).*)",
"destination": "/index.html"
}
]
}
vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
sourcemap: true,
},
});
I would greatly appreciate any input if something is very obvious to anyone regarding this. It really appears to be a configuration problem on my end but honestly it is not very obvious on how to get this to work reliably.
Many thanks!