javascript
50 lines · 6 steps
Serving private assets from a Next.js route
A Next.js route handler streams files from a private directory with path-traversal guards and ETag caching.
Explained by
highlit
1import { NextResponse } from 'next/server';
2import { readFile, stat } from 'node:fs/promises';
3import { join, extname, normalize } from 'node:path';
4import { createHash } from 'node:crypto';
5
6const ASSETS_DIR = join(process.cwd(), 'private', 'assets');
7
8const MIME_TYPES = {
9 '.css': 'text/css; charset=utf-8',
10 '.js': 'text/javascript; charset=utf-8',
11 '.svg': 'image/svg+xml',
12 '.png': 'image/png',
13 '.jpg': 'image/jpeg',
14 '.woff2': 'font/woff2',
15};
16
17export async function GET(request, { params }) {
18 const { slug } = await params;
19 const relative = normalize(slug.join('/'));
20
21 if (relative.startsWith('..')) {
22 return new NextResponse('Not found', { status: 404 });
23 }
24
25 const filePath = join(ASSETS_DIR, relative);
26
27 let contents, info;
28 try {
29 [contents, info] = await Promise.all([readFile(filePath), stat(filePath)]);
30 } catch {
31 return new NextResponse('Not found', { status: 404 });
32 }
33
34 const etag = `"${createHash('sha1').update(contents).digest('hex')}"`;
35
36 if (request.headers.get('if-none-match') === etag) {
37 return new NextResponse(null, { status: 304, headers: { ETag: etag } });
38 }
39
40 return new NextResponse(contents, {
41 status: 200,
42 headers: {
43 'Content-Type': MIME_TYPES[extname(filePath)] ?? 'application/octet-stream',
44 'Content-Length': String(info.size),
45 'Cache-Control': 'public, max-age=31536000, immutable',
46 'Last-Modified': info.mtime.toUTCString(),
47 ETag: etag,
48 },
49 });
50}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Normalizing and rejecting `..` paths keeps user input from escaping the intended directory.
- 2Content-hashed ETags let clients skip re-downloading unchanged files with a 304 response.
- 3Mapping extensions to MIME types with a fallback ensures browsers interpret every response correctly.
Related explainers
javascript
function evaluate(expression) { const tokens = tokenize(expression); let pos = 0;
Building a recursive descent calculator
parsing
recursion
operator-precedence
Intermediate
8 steps
go
package api import ( "crypto/sha256"
ETag conditional requests in Gin
http-caching
etag
conditional-requests
Intermediate
6 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react'; const cache = new Map(); const inflight = new Map();
Building a stale-while-revalidate hook in React
caching
request-deduplication
custom-hooks
Advanced
10 steps
javascript
import { useEffect, useRef, useState } from 'react'; export function useDelayedFlag(active, delay = 300) { const [visible, setVisible] = useState(false);
Delaying a loading spinner with a React hook
custom-hooks
debouncing
cleanup
Intermediate
8 steps
javascript
const SWIPE_THRESHOLD = 80; const MAX_TRANSLATE = 120; export function attachSwipeToDismiss(element, onDismiss) {
Building a swipe-to-dismiss gesture in JS
touch-events
gesture-detection
dom-manipulation
Intermediate
10 steps
javascript
const { pool } = require('./db'); function withTransaction() { return async (req, res, next) => {
A per-request transaction middleware in Express
middleware
database-transactions
connection-pooling
Advanced
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/serving-private-assets-from-a-next-js-route-explained-javascript-04f6/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.