How to Unscrew Your Hono App's CORS Configuration (for good)

It was on my 3rd year of college when I built a a certain web application that automated my job in extracurricular roles, and I did what most developers do: spun up a quick API (Hono, in this case), deployed the frontend to Vercel, and everything just worked. The magic of modern web development made deployment feel effortless, almost too effortless for my own good.
But as my application grew, I decided to expand beyond just using Vercel's domain. I added a custom domain and expected everything to continue working seamlessly. After all, I had already set up CORS, right?
Why CORS screws us all over
The dreaded red text appeared in my DevTools console. My API was giving me the cold shoulder with a classic CORS error on every POST + Preflight
request. The weird part? Requests from my Vercel domain worked perfectly fine, but my shiny new custom domain was getting stonewalled.
Looking at the network tab was like watching a murder scene unfold – a failed OPTIONS
request followed by a failed POST
request, all with the sinister "CORS error" status.
The jarring realization hit me after staring at my CORS configuration for what felt like hours:
app.use(
cors({
origin: [
'http://localhost:5173',
'https://my-app.vercel.app',
'https://mycustomdomain.dev/', // The culprit!
],
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
})
);
See that trailing slash at the end of my custom domain? That tiny, innocent-looking character was the difference between my application working and failing spectacularly. The browser was sending https://mycustomdomain.dev
(no trailing slash) as the origin, but my CORS configuration was looking for an exact match with the slash.
Fixing the Invisible Problem
The solution was embarrassingly simple:
origin: [
'http://localhost:5173',
'https://my-app.vercel.app',
'https://mycustomdomain.dev', // No more trailing slash!
],
One character removed, and suddenly the gates swung open. My API responded with the treasured 200 OK
status, and data began flowing again.
This would never have happened with platforms that handle CORS configuration automatically. But when you're crafting your own API, these tiny details become your responsibility.
The Docker Dilemma
But my journey didn't end there. As I embraced more DevOps practices, I decided to containerize my application with Docker and deploy it properly. This introduced a new challenge: how do I manage environment variables across different environments?
The traditional approach of copying a .env
file into the Docker image during build seemed convenient, but I quickly realized this would bake sensitive configuration directly into my image. Anyone pulling my image from Docker Hub could potentially extract those environment variables – definitely not what I wanted for my JWT secrets and database credentials.
Instead, I learned to leverage Docker Compose's environment configuration:
version: '3'
services:
api:
image: myusername/my-api:latest
environment:
- NODE_ENV=production
- CORS_ORIGINS=http://localhost:5173,https://my-app.vercel.app,https://mycustomdomain.dev
ports:
- "3000:3000"
This approach keeps sensitive data separate from the image itself and allows for different configurations in different environments.
Making CORS Configuration Dynamic
The final evolution in my CORS journey was making the configuration itself dynamic, pulling the allowed origins from environment variables:
const ALLOWED_ORIGINS = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map(origin => origin.trim())
: ['http://localhost:5173', 'https://my-app.vercel.app', 'https://mycustomdomain.dev'];
app.use(
cors({
origin: (origin) => {
console.log(`Received request from origin: ${origin}`);
if (!origin) return ALLOWED_ORIGINS[0]; // Handle non-browser requests
if (ALLOWED_ORIGINS.includes(origin)) {
return origin;
}
console.warn(`CORS request from non-allowed origin: ${origin}`);
return ALLOWED_ORIGINS[0]; // Default to first origin as fallback
},
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
})
);
This approach gives me complete control while also making my application more flexible and environment-aware.
Lessons from the CORS Trenches
My journey from a simple API to a properly configured, containerized application taught me valuable lessons:
- The devil is in the details - A single trailing slash can break your entire application
- Environment variables require strategy - Especially when containerizing applications
- Cloud platforms abstract away complexity - Services like Vercel handle a lot of this automatically, which is convenient but limits understanding
- Logging is your best friend - Adding logging to CORS requests helped identify the exact problem
- Dynamic configuration is worth the effort - Hardcoded values might work initially but will eventually cause headaches
While I sometimes miss the simplicity of letting platforms handle all this configuration for me, the control and knowledge I've gained through managing it myself have been worth the additional effort.
Have you encountered bizarre CORS issues in your applications? How did you solve them? Share your experiences in the comments below!