I’m encountering an inconsistent behavior with my API endpoint where:
curl -i http://x-realestate.fly.dev/api/public/homePage
returns JSON (HTTP 200)Browser requests to the same URL return HTML (the React index.html markup)
Scenario
User → x-realestate.fly.dev.
Nginx proxies / → x-realestate-client.fly.dev:3000 (frontend vite react).
Client calls /api/data → x-realestate.fly.dev/api/.
Nginx proxies /api/ → x-realestate-server.fly.dev:8000 (backend express).
Nginx Config Snippet
location ^~ /api/ {
access_log /var/log/nginx/api_access.log;
error_log /var/log/nginx/api_error.log;
proxy_pass http://server;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-Once "1";
if ($http_x_forwarded_once) {
return 444; # Close connection if already forwarded # KEEP (security measure)
}
add_header Content-Type application/json always;
# Cookie-specific additions:
proxy_cookie_domain ~\.fly.dev$ $host;
proxy_cookie_path / /;
proxy_set_header Cookie $http_cookie; # Forward original cookies
# Security headers for cookies:
add_header Set-Cookie "Path=/; HttpOnly; Secure; SameSite=None";
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
add_header 'Access-Control-Allow-Origin' 'https://x-realestate.fly.dev' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
# Handle preflight requests
if ($request_method = OPTIONS) {
add_header Content-Type application/json;
add_header Content-Length 0;
return 204;
}
# Cache control for auth endpoints
location ~* ^/api/(login|logout) {
add_header 'Cache-Control' 'no-store';
}
}
Observations
1 Direct backend access works
curl http://x-realestate-server.internal:8000/api/public/homePage # Returns JSON
2 Direct Nginx access works
curl -i http://x-realestate.fly.dev/api/public/homePage # Returns JSON
Browser DevTools shows:
- Request URL:
http://x-realestate.fly.dev/api/public/homePage
- Response headers include
Content-Type: text/html
(unexpected) - No CORS errors
What I’ve Tried
- Verified Nginx isn’t stripping
/api
prefix - Confirmed backend sets
res.json()
- Added explicit
Content-Type
headers in both Express and Nginx - updated the vite.config.prod.ts to ignore path with “/api/”
// Production configuration
export default defineConfig({
base: './',
plugins: [react()],
build: {
outDir: 'dist',
rollupOptions: {
input: 'index.html',
},
},
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': 'http://x-realestate-server.internal:8000',
},
},
});
Question
Why would the browser receive HTML while curl gets JSON, and how can I enforce JSON responses consistently?
this is the full Nginx.conf
worker_processes 1;
events {
worker_connections 1024;
}
http {
# TO FIX HASH WARNINGS
proxy_headers_hash_max_size 1024;
proxy_headers_hash_bucket_size 128;
include mime.types;
default_type application/octet-stream;
keepalive_timeout 65;
keepalive_requests 100;
# Upstream definitions
upstream client {
# server x-realestate-client.fly.dev:443;
server x-realestate-client.internal:3000;
keepalive 32;
keepalive_timeout 60s;
keepalive_requests 100;
}
upstream server {
# server x-realestate-server.fly.dev:8000;
server x-realestate-server.internal:8000;
keepalive 32;
keepalive_timeout 60s;
keepalive_requests 100;
}
server {
# Hardcoded NGINX_PORT
# listen 8888 default_server;
listen 8888;
server_name x-realestate.fly.dev;
set_real_ip_from 0.0.0.0/0;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# error handling
proxy_intercept_errors on;
error_page 500 502 503 504 /error.html;
location ^~ /api/ {
access_log /var/log/nginx/api_access.log;
error_log /var/log/nginx/api_error.log;
# proxy_pass http://server/api$1$is_args$args;
proxy_pass http://server;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-Once "1";
if ($http_x_forwarded_once) {
return 444; # Close connection if already forwarded # KEEP (security measure)
}
# Force JSON content type for API responses
# proxy_hide_header Content-Type;
add_header Content-Type application/json always;
# Cookie-specific additions:
proxy_cookie_domain ~\.fly.dev$ $host;
proxy_cookie_path / /;
proxy_set_header Cookie $http_cookie; # Forward original cookies
# Security headers for cookies:
add_header Set-Cookie "Path=/; HttpOnly; Secure; SameSite=None";
# Security headers
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header Connection "upgrade";
# proxy_set_header Upgrade $http_upgrade;
# proxy_connect_timeout 600;
# proxy_read_timeout 600;
# proxy_send_timeout 600;
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
# # CORS headers
# proxy_set_header Access-Control-Allow-Origin $http_origin;
# proxy_set_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
# proxy_set_header Access-Control-Allow-Headers 'Content-Type, Authorization';
add_header 'Access-Control-Allow-Origin' 'https://x-realestate.fly.dev' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
# Handle preflight requests
if ($request_method = OPTIONS) {
add_header Content-Type application/json;
add_header Content-Length 0;
return 204;
}
# Cache control for auth endpoints
location ~* ^/api/(login|logout) {
add_header 'Cache-Control' 'no-store';
}
}
location / {
# Add these headers for Fly's proxy
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://client;
proxy_http_version 1.1;
proxy_intercept_errors on;
error_page 502 503 504 /maintenance.html;
proxy_ssl_server_name on;
proxy_ssl_name $host;
# (for keepalive)
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
# KEEP for WebSocket support
# proxy_set_header Connection "upgrade";
}
location = /health {
access_log off;
add_header Content-Type text/plain;
return 200 '{"status":"ok"}'; # Static response instead of proxying
}
# Handle favicon too (optional)
location = /favicon.ico {
proxy_pass http://client/favicon.ico;
}
}
}