API Returns JSON via curl but HTML in Browser - Nginx Reverse Proxy Issue

I’m encountering an inconsistent behavior with my API endpoint where:

  • :white_check_mark: curl -i http://x-realestate.fly.dev/api/public/homePage returns JSON (HTTP 200)
  • :cross_mark: 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

  1. Verified Nginx isn’t stripping /api prefix
  2. Confirmed backend sets res.json()
  3. Added explicit Content-Type headers in both Express and Nginx
  4. 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;
        }
    }
}

Have a look at the request headers the browser is sending. I wonder if it is marking the request as Accept: text/html, and Express reacts differently to that compared to when that header is not set at all, which is what cURL will do.

thank you dear halfer, at the end it was the missing https protocol on the dockerfile arg for the domain… so its duplicate the domain.. now all good - thanks.

This topic was automatically closed 7 days after the last reply. New replies are no longer allowed.