Image uploads often work on localhost and fail after deployment. The reason is usually not Nuxt alone: the browser, API, reverse proxy, file system, and public media URL all participate. A mistake at any layer can produce a CORS error, 413 Request Entity Too Large, a permission failure, or an image URL that returns 404.

This tutorial builds a reliable Nuxt 4 upload pipeline with an external Node.js API and Nginx. The same debugging method also applies to other backend frameworks.

1. Map the complete upload flow

  1. The user selects a file in Nuxt.

  2. Nuxt sends a multipart request to the API domain.

  3. Nginx accepts and proxies the request.

  4. The API validates and stores the file.

  5. The API returns a public HTTPS URL.

  6. Nuxt displays or saves that URL.

  7. A separate browser request retrieves the image.

The upload and the later image request are separate operations. A successful upload response does not prove the returned URL is reachable.

2. Configure the API base URL

Keep environment-specific addresses in runtime config instead of hard-coding localhost or production domains in components.

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    apiSecret: process.env.API_SECRET,
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3001"
    }
  }
});

Only values under runtimeConfig.public are exposed to browser code. Private API credentials must remain server-side.

3. Upload with FormData

Let the browser generate the multipart boundary. Do not manually set Content-Type: multipart/form-data; an incorrect or missing boundary can make the API see an empty request.

// composables/useImageUpload.ts
type UploadResponse = { url: string };

export function useImageUpload() { const config = useRuntimeConfig(); const uploading = ref(false); const errorMessage = ref("");

async function uploadImage(file: File) { uploading.value = true; errorMessage.value = "";

try {
  const body = new FormData();
  body.append("image", file);

  return await $fetch<UploadResponse>("/media/images", {
    baseURL: config.public.apiBase,
    method: "POST",
    body,
    credentials: "include"
  });
} catch (error: any) {
  errorMessage.value =
    error?.data?.message || error?.message || "Upload failed";
  throw error;
} finally {
  uploading.value = false;
}

}

return { uploadImage, uploading, errorMessage }; }

If the API uses bearer tokens, add the Authorization header through your authentication layer. Never expose a private API secret through public runtime config.

4. Validate on both client and server

const allowedTypes = new Set([
  "image/jpeg",
  "image/png",
  "image/webp"
]);

function validateImage(file: File) { if (!allowedTypes.has(file.type)) { throw new Error("Choose a JPEG, PNG, or WebP image"); } if (file.size > 5 * 1024 * 1024) { throw new Error("Maximum file size is 5 MB"); } }

Client validation provides faster feedback, but the API must repeat it because browser code can be bypassed. The server should inspect the actual file signature, generate a random filename, and never treat an unchecked original filename as a storage path.

5. Configure CORS on the API

When frontend and API origins differ, the API must allow the exact frontend origin. Credentialed requests cannot use a wildcard origin.

import cors from "cors";

const allowedOrigins = new Set([ "https://app.example.com", "http://localhost:3000" ]);

app.use(cors({ origin(origin, callback) { if (!origin || allowedOrigins.has(origin)) { return callback(null, true); } callback(new Error("Origin not allowed")); }, credentials: true, methods: ["GET", "POST", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization"] }));

The browser may send an OPTIONS preflight before the upload. Confirm that Nginx forwards it and the API returns the appropriate CORS headers. A console CORS message can also hide a server error whose response lacks those headers, so inspect the Network panel.

6. Align Nginx and API body limits

Nginx's default client_max_body_size is 1 MB. If the application allows up to 5 MB, set a slightly larger proxy limit and keep strict API validation at 5 MB.

server {
    server_name api.example.com;
    client_max_body_size 6m;

location / {
    proxy_pass http://127.0.0.1:3001;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

}

Validate the Nginx configuration before reloading it. If the browser gets 413 and there is no application log, the proxy rejected the body before it reached Node.js.

7. Separate storage paths from public URLs

/srv/my-api/uploads/abc.webp is a server path, not a browser URL. Store the internal path separately and return an address such as https://media.example.com/images/abc.webp.

const publicUrl = new URL(
  "/images/" + filename,
  process.env.MEDIA_BASE_URL
).toString();

res.status(201).json({ url: publicUrl });

Absolute HTTPS URLs are simpler across browsers, mobile clients, and email. If you return a relative path, make sure Nuxt resolves it against the media base rather than its own origin.

8. Serve media correctly with Nginx

server {
    server_name media.example.com;

location /images/ {
    alias /srv/my-api/uploads/images/;
    try_files $uri =404;
    access_log off;
    expires 7d;
    add_header Cache-Control "public, immutable";
}

}

Trailing slashes matter with alias. The Nginx worker also needs permission to traverse parent directories and read the file. Use sensible ownership and group permissions instead of making the directory world-writable.

9. Display the uploaded file

<script setup lang="ts">
const imageUrl = ref("");
const { uploadImage, uploading, errorMessage } = useImageUpload();

async function onFileChange(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; if (!file) return;

validateImage(file); const result = await uploadImage(file); imageUrl.value = result.url; } </script>

<template> <input type="file" accept="image/jpeg,image/png,image/webp" :disabled="uploading" @change="onFileChange" > <p v-if="errorMessage">{{ errorMessage }}</p> <img v-if="imageUrl" :src="imageUrl" alt="Uploaded preview"> </template>

If Nuxt Image optimizes remote files, add the media hostname to its allowed domains or provider configuration. Test the original URL directly first; an optimizer cannot fetch an image that already returns 404.

10. Debug by status code

  • CORS error: compare the request Origin with the API allowlist; inspect OPTIONS and the actual response.

  • 413: raise the Nginx body limit and keep the application limit aligned.

  • 401 or 403: verify cookies, SameSite, HTTPS, tokens, and authorization.

  • 422: compare the multipart field name and check file type or size validation.

  • 500: inspect API logs for storage paths, disk space, or permission failures.

  • Upload succeeds, image returns 404: compare the stored path, returned URL, Nginx route, and filename character by character.

11. Repeatable 404 checklist

  1. Open the exact returned image URL in a new tab.

  2. Confirm the file exists at the expected server path.

  3. Check capitalization because Linux paths are case-sensitive.

  4. Verify the Nginx location, alias, and trailing slashes.

  5. Inspect Nginx error logs and permissions.

  6. Confirm the public URL uses the correct HTTPS hostname.

  7. Bypass caches if a file was replaced at the same URL.

Production checklist

  • Frontend and API URLs come from environment-aware config.

  • Both client and server validate type and size.

  • The API generates safe unique filenames.

  • CORS uses an explicit origin allowlist.

  • Nginx and application limits agree.

  • Uploads live outside the application build directory.

  • Media has a stable public HTTPS URL and least-privilege permissions.

  • Logs record a request ID and final filename.

  • Backups, retention rules, and orphan-file cleanup are defined.

Debug uploads as two operations: writing the file and serving the file. That distinction usually reveals the broken layer quickly.

References