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
publicStructuring your upload directory for production
Configuring Nginx to serve uploaded files
PM2 working directory pitfalls
A
next.config.tsrewrites approach as an alternative to NginxLinux 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.jpgThat is a filesystem path. The browser knows nothing about your server's filesystem. It requests a URL:
https://example.com/uploads/example.jpgSomething 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 diskThis 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 -- startThe 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.tsYou 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-appLook 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-appYou 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/uploadsIn 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.jpgYour 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 nginxOption 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-Controlto 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.jpgnamei -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 nginxThen verify that user can read the directory:
sudo -u www-data ls /var/www/my-app-data/uploadsDo 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/uploadsAdjust 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.jpg200: 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.logThen 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-appDid you reload Nginx after changing the config?
sudo nginx -t && sudo systemctl reload nginxAre 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/uploadsare served by Next.js automatically, but only from thepublicdirectory thatnext startis running from. Verify the working directory withpm2 describe.Use an
UPLOAD_DIRenvironment 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
aliasis the most efficient serving option. Thenext.config.tsrewrites 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 -Iand check Nginx error logs before touching frontend code.403means a permissions problem. Usenamei -lto 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?




