NIXX/DEVv1.16.0
ArticlesFavorites
Sign In
Sign In
Articles

Welcome to our blog

A curated collection of insightful articles, practical guides, and expert tips designed to simplify your workflow

Cover image for: Next.js File Uploads Work Locally but Break in Production? Here's How to Fix It (2026)
August 18, 20269 MIN READ min readBy ℵi✗✗

Next.js File Uploads Work Locally but Break in Production? Here's How to Fix It (2026)

File uploads work locally but images return 404 in production? The cause is almost always a mismatch between where files are saved and how your web server serves them. Here is how to fix it.

devopsnext.jsNode.js
ℵi✗✗

ℵi✗✗

Full-Stack Developer

Passionate about building tools and sharing knowledge with the developer community.

Was this helpful?

Popular Posts

  • Is Your Android Device ARM or ARM64? Here’s How to Check (2026 Guide)

    Is Your Android Device ARM or ARM64? Here’s How to Check (2026 Guide)

    4 MIN READ min read

  • NixOS vs. Arch Linux: Which One Belongs in Your Dev Setup?

    NixOS vs. Arch Linux: Which One Belongs in Your Dev Setup?

    5 MIN READ min read

  • How to Enable HTTPS on Localhost in Under 2 Minutes

    How to Enable HTTPS on Localhost in Under 2 Minutes

    3 MIN READ min read

  • Array Destructuring in PHP: A Practical Guide for Modern Developers

    Array Destructuring in PHP: A Practical Guide for Modern Developers

    5 MIN READ min read

Recommended Products

  • Dell 24 Monitor — SE2425HM Full HD

    Dell 24 Monitor — SE2425HM Full HD

    4.7
  • Hybrid ANC Bluetooth Headphones — 60H Playtime

    Hybrid ANC Bluetooth Headphones — 60H Playtime

    5.0
  • Apple MacBook Air M2

    Apple MacBook Air M2

    4.4
  • Samsung Galaxy S23

    Samsung Galaxy S23

    4.2

May contain affiliate links

Topics

webdev33productivity16cybersecurity12javascript11automation10guide8react8typescript8next.js6Node.js6php6tutorial6Android5freelancing5github actions5
+146 more topics →
🇺🇸USD ACCOUNTOpen a free US-based USD accountReceive & save in USD — powered by ClevaSponsoredInterserver Hosting#1 VALUEAffordable, reliable hosting from $2.50/mo99.9% uptimeSponsored

You build a file upload feature in Next.js. Everything works on your development machine: select an image, upload it, the file is saved, and the page displays it immediately. You deploy to a VPS, the upload still appears to succeed, the file is definitely on the server, but the browser shows a broken image.

This is one of the most common production gotchas in Next.js applications, and the root cause is almost always the same: saving a file to the server and serving that file to a browser are two completely different things.

What this covers:

  • Why filesystem uploads work locally but break in production

  • How Next.js serves files from public

  • Structuring your upload directory for production

  • Configuring Nginx to serve uploaded files

  • PM2 working directory pitfalls

  • A next.config.ts rewrites approach as an alternative to Nginx

  • Linux permissions and how to check them

  • A full troubleshooting checklist


The Core Problem: Filesystem Paths Are Not URLs

This is the most important concept in the entire guide.

Your server might have a file at:

/home/user/my-app/uploads/example.jpg

That is a filesystem path. The browser knows nothing about your server's filesystem. It requests a URL:

https://example.com/uploads/example.jpg

Something on your server must receive that HTTP request and translate it into the correct filesystem path. If nothing is configured to handle /uploads/, the browser cannot access the file even if it exists.

Browser
   |
   | GET /uploads/example.jpg
   v
Nginx (or Next.js)
   |
   | maps to /var/www/my-app/uploads/example.jpg
   v
File on disk

This distinction is responsible for almost every "works locally, broken in production" upload problem.


A Typical Broken Setup

Say your API route constructs the upload path like this:

import path from "path";

const uploadDir = path.join(process.cwd(), "public", "uploads");

Locally, process.cwd() resolves to something like /home/nixx/projects/my-app, so the file lands in /home/nixx/projects/my-app/public/uploads/example.jpg and Next.js serves it at /uploads/example.jpg without any extra configuration.

On the VPS you run:

npm run build
pm2 start npm --name my-app -- start

The upload creates /home/user/my-app/public/uploads/example.jpg. The file exists. But the browser requests https://example.com/uploads/example.jpg and gets a 404.

The natural question is: if the file is there, why can the browser not access it?


How Next.js Serves Files from public

Next.js serves static files placed inside the top-level public directory from the root URL automatically. A file at public/uploads/example.jpg is accessible at /uploads/example.jpg.

my-app/
├── app/
├── public/
│   └── uploads/
│       └── example.jpg
└── next.config.ts

You can reference it directly:

<img src="/uploads/example.jpg" alt="Uploaded image" />

Or with next/image:

import Image from "next/image";

<Image
  src="/uploads/example.jpg"
  alt="Uploaded image"
  width={800}
  height={600}
/>

This is fine for simple cases, but it only works if files are actually being saved into the public directory that next start is serving from. In production with PM2, that working directory is not always what you expect.


Check Where Your Application Is Actually Running

Do not guess the working directory. If you are using PM2, check it:

pm2 describe your-app

Look for the cwd field in the output. You can also add a temporary log to your API route:

console.log("cwd:", process.cwd());
console.log("uploadDir:", uploadDir);

Restart the app and check the logs:

pm2 restart your-app
pm2 logs your-app

You may find that PM2 is launching the application from /var/www/example.com while you assumed /home/user/example.com. That mismatch means process.cwd() resolves to a different directory and your uploads land somewhere unexpected.

To make this predictable, specify the working directory explicitly in your PM2 ecosystem file:

module.exports = {
  apps: [
    {
      name: "my-app",
      cwd: "/var/www/my-app",
      script: "npm",
      args: "start",
    },
  ],
};

Constructing the Upload Path Correctly

Avoid hardcoded absolute paths and avoid bare relative paths like ./uploads, which depend entirely on whatever directory the process happens to start from.

The most portable approach is to use an environment variable with a process.cwd() fallback:

import path from "path";

const uploadDir =
  process.env.UPLOAD_DIR ||
  path.join(process.cwd(), "public", "uploads");

In your production .env:

UPLOAD_DIR=/var/www/my-app-data/uploads

In development you can leave the variable unset and let it fall back to the local public/uploads path.

Before writing any file, ensure the directory exists:

import fs from "fs/promises";
import path from "path";

const uploadDir =
  process.env.UPLOAD_DIR ||
  path.join(process.cwd(), "public", "uploads");

await fs.mkdir(uploadDir, { recursive: true });

const filePath = path.join(uploadDir, fileName);
await fs.writeFile(filePath, buffer);

Keeping Uploads Separate from Your Application

If your deployment process does a fresh git pull and rebuild, files inside public/uploads can be wiped. User uploads are runtime data, not source code, and they should live outside the application directory.

A clean production layout on a VPS:

/var/www/
├── my-app/
│   ├── .next/
│   ├── app/
│   ├── public/
│   ├── package.json
│   └── next.config.ts
│
└── my-app-data/
    └── uploads/
        ├── image-1.jpg
        └── image-2.jpg

Your application saves files to /var/www/my-app-data/uploads and returns public URLs like /uploads/image-1.jpg. Something still needs to map that URL to the filesystem path. You have three options.


Option 1: Nginx alias

If Nginx is already in front of your Next.js application, this is the simplest and most efficient approach. Add a location block that serves the uploads directory directly:

server {
    server_name example.com;

    location /uploads/ {
        alias /var/www/my-app-data/uploads/;
    }

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;

        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;
    }
}

Now https://example.com/uploads/image-1.jpg maps directly to /var/www/my-app-data/uploads/image-1.jpg without the request ever reaching Next.js.

alias vs root: With alias, Nginx replaces the matched location prefix with the configured path. With root, Nginx appends the full URI to the configured path. Both can work, but alias is easier to reason about when the URL prefix and directory name differ.

# alias: /uploads/photo.jpg → /var/www/my-app-data/uploads/photo.jpg
location /uploads/ {
    alias /var/www/my-app-data/uploads/;
}

# root: /uploads/photo.jpg → /var/www/my-app-data/uploads/photo.jpg
location /uploads/ {
    root /var/www/my-app-data;
}

Note the trailing slash on the alias path. Omitting it is a common source of misconfiguration.

After editing Nginx, always test before reloading:

sudo nginx -t
sudo systemctl reload nginx

Option 2: next.config.ts Rewrites

If you are not running Nginx or you want to keep the routing inside Next.js, you can use rewrites in next.config.ts to proxy requests for /uploads/* to a Next.js API route that reads and streams the file.

First, add the rewrite:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: "/uploads/:filename*",
        destination: "/api/uploads/:filename*",
      },
    ];
  },
};

export default nextConfig;

Then create the API route at app/api/uploads/[...filename]/route.ts:

import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";

const uploadDir =
  process.env.UPLOAD_DIR ||
  path.join(process.cwd(), "public", "uploads");

export async function GET(
  _req: NextRequest,
  { params }: { params: { filename: string[] } }
) {
  const filename = params.filename.join("/");
  const filePath = path.join(uploadDir, filename);

  try {
    const file = await fs.readFile(filePath);
    const ext = path.extname(filename).toLowerCase();

    const contentTypes: Record<string, string> = {
      ".jpg": "image/jpeg",
      ".jpeg": "image/jpeg",
      ".png": "image/png",
      ".gif": "image/gif",
      ".webp": "image/webp",
      ".pdf": "application/pdf",
    };

    const contentType =
      contentTypes[ext] ?? "application/octet-stream";

    return new NextResponse(file, {
      headers: { "Content-Type": contentType },
    });
  } catch {
    return new NextResponse("Not found", { status: 404 });
  }
}

Now /uploads/image-1.jpg hits the rewrite, gets routed to /api/uploads/image-1.jpg, and the API route reads the file from disk and streams it back.

This approach is slightly less efficient than serving files directly through Nginx because every request passes through Node.js. For a small application it is perfectly fine, and it gives you a natural place to add authentication or access control later if you need it.

Note: Do not use this pattern for large files or high-traffic applications without adding caching headers. Add Cache-Control to the response headers to avoid unnecessary re-reads on every request.


Option 3: Next.js API Route Without Rewrites

For private files where you want per-request access control, skip the rewrite and expose the route directly:

// app/api/files/[id]/route.ts

import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";

const uploadDir = process.env.UPLOAD_DIR!;

export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  // Add your auth check here before serving the file
  const filePath = path.join(uploadDir, params.id);

  try {
    const file = await fs.readFile(filePath);
    return new NextResponse(file, {
      headers: { "Content-Type": "image/jpeg" },
    });
  } catch {
    return new NextResponse("Not found", { status: 404 });
  }
}

Use this pattern for invoices, private documents, user records, or any file that should not be publicly accessible to anyone who knows the URL. For public profile images, blog images, and product photos, the Nginx or rewrites approaches are simpler.


Check Linux Permissions

A common failure mode: your Node.js process can write the file but Nginx cannot read it.

Check the directory and its parents:

namei -l /var/www/my-app-data/uploads/example.jpg

namei -l is useful because Linux permissions apply to every directory in the path, not just the final file. If any parent directory is not traversable by the Nginx user, the request will fail with a 403.

Find which user Nginx runs as:

ps aux | grep nginx

Then verify that user can read the directory:

sudo -u www-data ls /var/www/my-app-data/uploads

Do not solve permission problems with chmod -R 777. Instead, give the Nginx user read and execute access to the upload directory and read access to the files:

sudo chown -R youruser:www-data /var/www/my-app-data/uploads
sudo chmod -R 750 /var/www/my-app-data/uploads

Adjust to match your actual user and Nginx group.


Diagnosing the Problem

Work through these steps before touching your React components.

Does the file actually exist?

find /var/www -name "example.jpg"

If the path it returns does not match what your web server is configured to serve, that is the problem.

Can you access the URL directly?

Open https://example.com/uploads/example.jpg in the browser. If it fails here, the problem is routing or permissions, not your frontend.

What HTTP status are you getting?

curl -I https://example.com/uploads/example.jpg
  • 200: The file is being served. The problem is in your frontend code.

  • 404: Routing or path mismatch. The file is not where the server is looking.

  • 403: Permissions. The web server cannot read the file or traverse the directory.

  • 502: The request reached Next.js or the proxy but something failed upstream.

Check Nginx logs:

sudo tail -f /var/log/nginx/error.log

Then make the request. The error log will tell you exactly whether the problem is a missing file, a path mismatch, or a permissions denial.

Is PM2 using the directory you expect?

pm2 describe my-app

Did you reload Nginx after changing the config?

sudo nginx -t && sudo systemctl reload nginx

Are uploads being deleted during deployment?

If your deployment replaces the application directory, make sure the upload directory lives outside it.


A Note on Filesystem Storage at Scale

Filesystem storage works well for a single VPS with a persistent disk. It becomes a problem if you move to multiple servers, Docker containers without persistent volumes, or auto-scaling infrastructure. If Server A handles the upload and Server B handles the next request, Server B will not have the file.

For most small Next.js applications on a single VPS, a properly configured filesystem setup is entirely reasonable. As the application grows, S3-compatible object storage (AWS S3, Cloudflare R2, MinIO) becomes worth evaluating for durability, backups, and multi-server deployments.


Key Takeaways

  • A file existing on the server does not mean a browser can access it. Something must map the public URL to the filesystem path.

  • Files in public/uploads are served by Next.js automatically, but only from the public directory that next start is running from. Verify the working directory with pm2 describe.

  • Use an UPLOAD_DIR environment variable rather than hardcoded paths or bare relative paths like ./uploads.

  • Keep user uploads outside the application directory so deployments cannot accidentally delete them.

  • For public files, Nginx alias is the most efficient serving option. The next.config.ts rewrites approach works without Nginx.

  • For private files, use an API route with authentication before streaming the file.

  • When debugging, test the URL directly with curl -I and check Nginx error logs before touching frontend code.

  • 403 means a permissions problem. Use namei -l to check every directory in the path, not just the upload directory itself.


Conclusion

The "works locally, broken in production" pattern with file uploads almost always comes down to one of three things: the file is being saved somewhere different from where the web server is looking, nothing is configured to serve that directory over HTTP, or the web server does not have permission to read the files.

Once you separate the concepts of filesystem path and public URL, the debugging process becomes straightforward. Check that the file exists, confirm the path matches your server configuration, test the URL directly, and check the HTTP status code before touching your React components.

For a VPS deployment, saving uploads to a directory outside your application tree and serving them via Nginx alias or a next.config.ts rewrite keeps things clean, survives deployments, and gives you a clear path to adding access control later if you need it.

If you are running into a specific error not covered here, drop the HTTP status code, your Nginx config (redact the domain), and how you are constructing the upload path in the comments.


Are you storing uploads on the filesystem or using object storage, and what pushed you toward that choice?

Topics
devopsnext.jsNode.js
Interserver Hosting#1 VALUEAffordable, reliable hosting from $2.50/mo99.9% uptimeSponsored

Discussion

Join the discussion

Sign in to share your thoughts and engage with the community.

Sign In
Loading comments…

Continue Reading

More Articles

View all
Cover image for: Build a Fun Alphabet Reader with TypeScript, Vite & Speech Synthesis API
Jun 27, 20254 MIN READ min read

Build a Fun Alphabet Reader with TypeScript, Vite & Speech Synthesis API

An interactive, educational project for beginners to learn modern frontend development.

Cover image for: Why You Should Use TypeScript in Every JavaScript Project
Jul 23, 20255 MIN READ min read

Why You Should Use TypeScript in Every JavaScript Project

JavaScript gets the job done—but TypeScript helps you write cleaner, safer, and easier-to-maintain code. Here’s why it’s worth using everywhere.

Cover image for: Array Destructuring in PHP: A Practical Guide for Modern Developers
Mar 12, 20255 MIN READ min read

Array Destructuring in PHP: A Practical Guide for Modern Developers

From PHP 7.1 to 8.1—learn how array destructuring simplifies variable assignment, reduces boilerplate, and improves readability in modern PHP development.

Cover image for: React Authentication with JWT: A Step-by-Step Guide
Oct 17, 20257 MIN READ min read

React Authentication with JWT: A Step-by-Step Guide

Learn how to implement secure JWT authentication in React. From login to route protection and API calls, this guide covers everything you need to know.

|Made with · © 2026|TermsPrivacy
AboutBlogContact

Free, open-source tools for developers and creators · Community driven