Learn to send JSON with res.json, serve and download files with res.sendFile and res.download, and redirect users safely with res.redirect in Express 5.

Bishal Biswas
Self Learner
What you will learn
- How
res.json()turns JavaScript values into JSON, and its small surprises - How to send a file from disk with
res.sendFile(), using a safe absolute path - How to trigger a file download with
res.download()andres.attachment() - How to send users to another page with
res.redirect(), and which status code to use - What changed for redirects and file sending in Express 5
Sending JSON with res.json()
JSON (JavaScript Object Notation) is a text format for data. It looks almost exactly like a JavaScript object. It is the language most APIs speak, because every programming language can read it.
You met res.json() in the last lesson. Here it is in a small but realistic API:
// app.js
import express from 'express';
const app = express();
const books = [
{ id: 1, title: 'Clean Code', inStock: true },
{ id: 2, title: 'Eloquent JavaScript', inStock: false },
];
app.get('/api/books', (req, res) => {
res.json(books);
});
app.get('/api/books/:id', (req, res) => {
const book = books.find((b) => b.id === Number(req.params.id));
if (!book) {
return res.status(404).json({ error: 'Book not found' });
}
res.json(book);
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Listening on http://localhost:3000');
});
curl http://localhost:3000/api/books/1
# {"id":1,"title":"Clean Code","inStock":true}
curl http://localhost:3000/api/books/99
# {"error":"Book not found"}
Tip: Even error responses should be JSON in an API. A frontend developer can then always call
response.json()without worrying about what came back.
What JSON can and cannot hold
res.json() uses JSON.stringify() under the hood. That means some JavaScript values change on the way out.
| JavaScript value | What the client receives |
|---|---|
{ a: 1 }, [1, 2] |
The same object or array |
'text', 42, true, null |
The same value |
new Date() |
An ISO string like "2026-10-25T09:13:00.000Z" |
{ a: undefined } |
{} (the key disappears) |
| A function | Removed |
A Map or Set |
{} (empty object) |
So if you store dates as Date objects, the client gets strings and must turn them back into dates. And if a field is undefined, it vanishes. Use null when you want “no value” to show up.
Pretty-printed JSON while learning
By default, JSON comes out on one line to save bytes. While learning, you can ask Express to add spaces:
app.set('json spaces', 2); // indent JSON with 2 spaces
app.set() changes an Express setting. You will meet more settings in Environment Variables and Configuration. Turn this one off in production, since the extra spaces make responses bigger.
A consistent response shape
As your API grows, it helps to wrap data the same way every time. A common pattern is:
app.get('/api/books', (req, res) => {
res.json({
data: books,
count: books.length,
});
});
The client can then always look in data for the result. You will design APIs properly in Designing RESTful Routes.
Sending a file with res.sendFile()
Sometimes the answer is a file: a PDF, an image or an HTML page. res.sendFile(path) reads the file from disk and streams it to the client. It sets the Content-Type from the file extension, so .pdf becomes application/pdf and .png becomes image/png.
There is one rule you must remember: the path must be absolute. An absolute path is a full path from the root of the drive, like /home/me/app/files/menu.pdf or C:\app\files\menu.pdf. A relative path like files/menu.pdf will throw an error.
Why? Because a relative path depends on the folder you started Node from. Run your app from a different folder and the file “moves”. Absolute paths remove that guesswork.
Building an absolute path
In ES modules, import.meta.dirname gives you the absolute path of the folder that contains the current file. Combine it with path.join():
my-app/
app.js
files/
menu.pdf
welcome.html
// app.js
import express from 'express';
import path from 'node:path';
const app = express();
app.get('/menu', (req, res) => {
const filePath = path.join(import.meta.dirname, 'files', 'menu.pdf');
res.sendFile(filePath);
});
app.get('/welcome', (req, res) => {
res.sendFile(path.join(import.meta.dirname, 'files', 'welcome.html'));
});
Open http://localhost:3000/menu in a browser and the PDF shows up right in the tab.
Note:
import.meta.dirnameneeds Node.js 20.11 or newer. On older versions use this instead:const dirname = path.dirname(fileURLToPath(import.meta.url));withimport { fileURLToPath } from 'node:url';
The root option: safer file paths
Often the file name comes from the URL, like /docs/guide.pdf. That is dangerous if you are careless. A visitor could ask for ../../.env and try to read your secret files. This trick is called path traversal.
The root option protects you. You give a relative file name plus a root folder, and Express refuses any path that tries to climb out of that folder.
// app.js
const docsFolder = path.join(import.meta.dirname, 'files', 'docs');
app.get('/docs/:name', (req, res) => {
res.sendFile(req.params.name, { root: docsFolder });
});
| Request | Result |
|---|---|
/docs/guide.pdf |
Sends files/docs/guide.pdf |
/docs/missing.pdf |
404 Not Found |
/docs/..%2F..%2Fapp.js |
403 Forbidden (blocked) |
Warning: Never build a file path by gluing user input onto a folder yourself, like
path.join(folder, req.params.name), and passing it withoutroot. Always use therootoption, or check the name against a list of allowed files.
Handling errors from sendFile
If the file does not exist, Express passes the error on and the client gets a 404. If you want to handle it yourself, give sendFile a callback. The callback runs when sending finishes or fails.
app.get('/report', (req, res, next) => {
const file = path.join(import.meta.dirname, 'files', 'report.pdf');
res.sendFile(file, (err) => {
if (err) {
console.error('Could not send report:', err.message);
next(err); // hand it to Express's error handling
}
});
});
next is the third argument a handler can receive. Calling next(err) hands the error to Express. You will learn about it in the middleware and error-handling lessons, starting with How the Middleware Stack Works.
Note: Express 4 also had
res.sendfile()with a lowercase “f”. It was removed in Express 5. Useres.sendFile().
Downloading files with res.download()
res.sendFile() lets the browser display the file if it can. Sometimes you want the browser to save it instead, with a “Save as” dialog or straight into the Downloads folder.
That is controlled by a response header called Content-Disposition. res.download() sets it for you.
app.get('/download/invoice', (req, res) => {
const file = path.join(import.meta.dirname, 'files', 'invoice-2026-10.pdf');
// second argument: the file name the user sees
res.download(file, 'my-invoice.pdf');
});
The response includes this header:
Content-Disposition: attachment; filename="my-invoice.pdf"
res.download() also accepts the same root option and an error callback, just like sendFile.
Downloading data you create on the fly
What if the file does not exist on disk, because you build it in code? Use res.attachment() to set the header, then send the data.
app.get('/export/books.csv', (req, res) => {
const lines = ['id,title,inStock', ...books.map((b) => `${b.id},${b.title},${b.inStock}`)];
res.attachment('books.csv'); // sets Content-Disposition and Content-Type
res.send(lines.join('\n'));
});
curl -i http://localhost:3000/export/books.csv
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="books.csv"
Content-Type: text/csv; charset=utf-8
id,title,inStock
1,Clean Code,true
2,Eloquent JavaScript,false
| Method | Browser behaviour | File source |
|---|---|---|
res.sendFile(path) |
Shows the file if it can (PDF, image, HTML) | Disk |
res.download(path, name) |
Saves the file | Disk |
res.attachment(name) + res.send(data) |
Saves the file | Data in memory |
Tip: For very large files or data, sending everything at once uses a lot of memory. Streaming Responses and Backpressure shows how to send data piece by piece.
Redirects with res.redirect()
A redirect tells the client: “What you want is somewhere else, go there.” The server sends a special status code and a Location header with the new URL. The browser then automatically makes a second request to that URL.
Browser Server
| GET /old-blog |
|---------------------------------------->|
| |
| 302 Found |
| Location: /blog |
|<----------------------------------------|
| |
| GET /blog (automatic) |
|---------------------------------------->|
| |
| 200 OK <html>...blog...</html> |
|<----------------------------------------|
The simplest redirect:
app.get('/old-blog', (req, res) => {
res.redirect('/blog');
});
app.get('/blog', (req, res) => {
res.send('<h1>Blog</h1>');
});
By default, res.redirect() uses status 302 Found, which means “temporarily somewhere else”.
Choosing the redirect status code
To use a different code, pass it first:
app.get('/old-pricing', (req, res) => {
res.redirect(301, '/pricing'); // moved permanently
});
Warning: Express 4 also accepted
res.redirect('/pricing', 301), with the URL first. That order is gone in Express 5. The status code always comes first.
| Code | Name | Use it when |
|---|---|---|
301 |
Moved Permanently | A page moved forever. Browsers and search engines remember it. |
302 |
Found | A temporary redirect. The default. |
303 |
See Other | After a form POST, send the user to a page with GET. |
307 |
Temporary Redirect | Like 302, but the browser must keep the same method and body. |
308 |
Permanent Redirect | Like 301, but the browser must keep the same method and body. |
Common mistake: Using
301while testing. Browsers cache permanent redirects, sometimes for a long time. If you later change your mind, your own browser keeps following the old redirect. Use302until you are sure.
The Post/Redirect/Get pattern
When a user submits an HTML form, you should redirect after saving. Otherwise, refreshing the page re-submits the form and creates duplicates. Redirecting with 303 after a POST is called the Post/Redirect/Get pattern.
app.use(express.urlencoded()); // read HTML form data into req.body
app.post('/contact', (req, res) => {
console.log('Message from', req.body.email);
res.redirect(303, '/thanks');
});
app.get('/thanks', (req, res) => {
res.send('<p>Thanks, we got your message.</p>');
});
Reading form data with express.urlencoded() is covered in Built-in Body Parsers.
Redirecting “back”
Express 4 had a shortcut, res.redirect('back'), to send users to the page they came from. It was removed in Express 5. Read the Referer header yourself, with a safe fallback:
app.post('/cart/add', (req, res) => {
// ...add the item...
res.redirect(req.get('Referrer') || '/');
});
req.get('Referrer') and req.get('Referer') both work. The header is famously misspelled in the HTTP standard, so Express accepts both spellings.
Never redirect to an unchecked URL
A common login pattern is /login?next=/dashboard, which sends the user back after logging in. If you redirect to req.query.next without checking it, an attacker can send people a link like /login?next=https://evil.example.com. Your trusted site then forwards them to a fake one. This is called an open redirect.
app.get('/after-login', (req, res) => {
const next = req.query.next;
// only allow paths on our own site, like "/dashboard"
const isSafe =
typeof next === 'string' &&
next.startsWith('/') &&
!next.startsWith('//') &&
!next.startsWith('/\\');
res.redirect(isSafe ? next : '/');
});
The extra checks matter because browsers treat //evil.example.com (and even /\evil.example.com) as a link to another site.
Common mistakes
Passing a relative path to res.sendFile(). You get TypeError: path must be absolute or specify root to res.sendFile. Build the path with path.join(import.meta.dirname, ...) or use the root option.
Responding after a redirect. res.redirect() sends a complete response. Do not call res.send() after it. Use return res.redirect(...) inside if blocks.
Getting the argument order wrong. It is res.redirect(301, url), not res.redirect(url, 301).
Sending huge JSON arrays. Returning 100,000 records in one response is slow for everyone. You will learn to split results into pages in Pagination, Filtering and Sorting.
Key takeaways
res.json()sends JSON. Dates become strings, andundefinedfields disappear.res.sendFile()needs an absolute path. Useimport.meta.dirnamewithpath.join(), or therootoption for user-supplied file names.res.download()andres.attachment()make the browser save a file instead of showing it.res.redirect(url)sends a302by default. Put a custom status code first:res.redirect(301, url).res.redirect('back')is gone in Express 5. Usereq.get('Referrer') || '/'.- Only redirect to URLs you have checked, to avoid open redirects.
Practice
- Add a
files/folder with a small PDF or image. CreateGET /viewthat shows it in the browser andGET /savethat downloads it with a custom file name. - Create
GET /api/books.csvthat downloads thebooksarray as a CSV file built in code. - Create
GET /docs/:namewith therootoption. Try a normal file, a missing file and..%2Fapp.js, and note the status code for each withcurl -i. - Make
/homepermanently redirect to/, and/saletemporarily redirect to/products?tag=sale. Check theLocationheader withcurl -i.
What’s next
You have used several status codes and headers already. Next, get to know them properly in Status Codes and Response Headers.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready