Static files and the public folder
Serve files from public in Next.js, import a file to get a hashed URL cached for a year, read each Cache-Control header, and add an app icon.
- 01Serve a file from publicGive a file in public its address, and say what Next.js sends with it and why browsers check it again every time.
- 02Import a file insteadImport an image from your code, read its src, width and height, and tell why its new name lets browsers keep it for a year.
- 03Pick the right placeChoose between public, an import and an icon file in app, and set a Cache-Control header of your own when a public file needs one.
The tea shop's logo, served from public
A static file is a file the server sends exactly as it is saved, the same for every visitor: a picture, a PDF, a text file. Next.js has two ways to serve one. You can put it in the public folder, or import it from your code. The two ways give the file different addresses, and browsers keep each one for a different time. Start with the shop's logo, a small picture of a teacup, saved as public/logo.svg. (SVG is a picture format written as text, so you can read the file in the example.)
app/page.tsxexport default function Home() { return ( <main> <img src="/logo.svg" alt="Tea shop logo" width={120} height={80} /> <h1>Tea shop</h1> <p>Green tea, mint tea and lemon tea.</p> </main> );}
A picture of the page in real Chrome, in a window 800 px wide, taken once the page had loaded with its styles. Open it full size
This is a real Next.js 16.4.3 project, built with next build and run with next start. Line 4 of app/page.tsx is a plain <img> whose src is /logo.svg: the file's path inside public, starting at the top of the site. The Browser tab shows a picture of the page in real Chrome, with the green teacup above the heading.
Now pick /logo.svg above the page, then open the Network tab. The server sent the file's text, byte for byte, with two headers worth reading:
Content-Type: image/svg+xmltells the browser what kind of file it is. Next.js picks it from the file's ending,.svg.Cache-Control: public, max-age=0tells browsers, and other caches on the way, how long they may keep a copy. A cache is a place that stores a copy of a response, so it doesn't have to be downloaded again. Here, a copy may be stored, but it's out of date the moment it arrives.
/logo.svg. The browser asks for it, and Next.js sends public/logo.svg byte for byte, with Cache-Control: public, max-age=0.Project structure gave public a first look, with a text file and the same header. A picture you import instead gets a different address and a different header.
A tea shop pins its price list on the door. The owner can replace the sheet whenever prices change, and the door stays the same, so a regular customer reads it again on every visit. The shop also prints a catalog with an edition number on the cover. Edition 7 never changes. When prices change, the shop prints edition 8. A customer who owns edition 7 never has to ask whether it's still edition 7.
- In real life: The price list pinned on the shop's door
- In Next.js: A file in
public, at/logo.svg - In real life: Anyone can walk up and read it
- In Next.js: Anyone can download it
- In real life: The owner may swap the sheet any day, so regulars read it again on every visit
- In Next.js:
max-age=0: check with the server before using a stored copy - In real life: A catalog with an edition number on the cover
- In Next.js: An imported file, with a name the build makes
- In real life: Edition 7 never changes; new prices mean edition 8
- In Next.js:
immutable: new content gets a new name
A file in public keeps its own address forever, so browsers must check it again before every use. A file you import gets a new name from the build, which changes whenever the file changes, so browsers may keep it for a year. Use public when the address itself matters. Import a file when only your own code uses it.
What the public folder sends
Every file in public gets an address: its path inside the folder, with / in front. Folders inside public become parts of the address, so public/teas/mint.svg is /teas/mint.svg. The word public is never part of it. Here the page asks for /public/logo.svg:
app/page.tsxexport default function Home() { return ( <main> <img src="/public/logo.svg" alt="Tea shop logo" width={120} height={80} /> <h1>Tea shop</h1> <p>Green tea, mint tea and lemon tea.</p> </main> );}
A picture of the page in real Chrome, in a window 800 px wide, taken once the page had loaded with its styles. Open it full size
In the Browser tab, real Chrome shows the logo's alt text where the picture should be. Pick /public/logo.svg and open the Network tab: the server answered 404, with Next.js's page for addresses that don't exist. There's no public/public/logo.svg, and no page at that address.
Four more things are true of every file in public:
- It isn't built.
next builddoesn't read, change or rename it. In the recording, the logo's text is exactly the file's text. - It's public. Anyone who knows the address can download it, linked or not. Secrets never go there.
- A missing file shows up late. A typo in
"/logo.svg"builds without a warning, and only a visitor's browser finds the 404. - Its list is made when the server starts, in the recorded version. In Next.js 16.4.3, the production server that
next startruns reads the names of the files inpubliconce, when it starts, and serves only those, as its source shows. A file copied in later answers 404 there until the server starts again. That's how this version works, not a promise in the docs, and a host can serve the folder its own way. Either way, pictures that people upload while the shop runs belong in a storage service made for that, not inpublic.
One more line in the recorded HTML is worth a look. In the Network tab of the first example, this recording's <head> has <link rel="preload" href="/logo.svg" as="image">. React's server renderer added it, because the <img> isn't marked loading="lazy": it tells the browser to start downloading the logo early, before it reaches the <img> in the body.
Import a file, and the build names it
The second way: keep a picture next to the code that uses it, and import it like a module (import and export shows how imports work). The shop has a leaf picture in app/leaf.svg, and the home page imports it:
app/page.tsximport leaf from "./leaf.svg"; export default function Home() { return ( <main> <img src="/logo.svg" alt="Tea shop logo" width={120} height={80} /> <h1>Tea shop</h1> <img src={leaf.src} alt="A tea leaf" width={leaf.width} height={leaf.height} /> <p>The leaf is at {leaf.src}</p> </main> );}
A picture of the page in real Chrome, in a window 800 px wide, taken once the page had loaded with its styles. Open it full size
Line 1 imports the file. What it gives back isn't the picture: it's a small object that Next.js makes during the build, with three values you use:
leaf.src, the address the build gave the file. Line 8 puts it in the<img>, and line 9 prints it.leaf.widthandleaf.height, read from the file itself. Here they're80and80, from thewidthandheightin line 1 ofapp/leaf.svg. A picture with its size in the HTML gets its space before it loads, so the text under it doesn't jump.
The Browser tab shows the page in real Chrome. The leaf's address, printed under it, is /_next/static/media/leaf.2z1sukzkjtw3f.svg. That address comes from the build, not from you: a generated address with a hash in it, a short code the build makes, which changes when the file changes. The folder, the name and the hash's format are this recording's, chosen by the bundler, and can differ in another version or setup, so code always uses leaf.src. Pick the third response in the Network tab, the leaf itself: Cache-Control: public, max-age=31536000, immutable.
Try a redesign. Before you click, guess: which of the two addresses changes when both pictures turn orange?
app/leaf.svg<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80"> <path d="M10 70 C10 30 40 10 70 10 C70 40 50 70 10 70 Z" fill="#2f855a" /></svg>
A picture of the page in real Chrome, in a window 800 px wide, taken once the page had loaded with its styles. Open it full size
The shop as it was. The page prints the leaf's address: a hashed address the build made. The logo is at /logo.svg.
In this recording, the leaf moved to /_next/static/media/leaf.0nag7zihy53fz.svg. New content, a new hashed address, and the new HTML points at it. The logo is still at /logo.svg, though its content changed too.
public, both logos have the same address, /logo.svg, so a stored copy may be the old one.Importing has one more thing going for it. A wrong path stops the build instead of showing a 404 to visitors. With app/leaf.svg deleted, the build stops at line 1 of the page:
app/page.tsximport leaf from "./leaf.svg"; export default function Home() { return ( <main> <img src="/logo.svg" alt="Tea shop logo" width={120} height={80} /> <h1>Tea shop</h1> <img src={leaf.src} alt="A tea leaf" width={leaf.width} height={leaf.height} /> <p>The leaf is at {leaf.src}</p> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 1 error:./app/page.tsx:1:1Error: Module not found: Can't resolve './leaf.svg'> 1 | import leaf from "./leaf.svg"; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 2 | 3 | export default function Home() { 4 | return ( https://nextjs.org/docs/messages/module-not-found at <unknown> (https://nextjs.org/docs/messages/module-not-found)Each row compares the two ways on one question:
| Question | A file in public | An imported file |
|---|---|---|
| Where the file lives | In public, like public/logo.svg. | Anywhere in your code, like app/leaf.svg. |
| How the page points at it | By its address, written by hand: "/logo.svg". | With import leaf from "./leaf.svg", then leaf.src. |
| Its address | The same as its path in public, forever. | A generated address with a hash in it, made by the build. It changes when the file changes. |
| How long browsers keep it | They check it again before every use. | For a year, without asking. |
| A missing file | Found only when someone asks for it: a 404. | The build fails, because the import can't be found. |
Imported pictures also work with next/image: <Image src={leaf} alt="A tea leaf" /> reads the width and height from the object, so you don't write them. The Optimizing images lesson in this stage teaches it.
How long browsers keep each file
Cache-Control is a response header made of instructions for caches. The recorded files use three:
public: any cache may store the response, the browser's own and the shared ones on the way, like a CDN (a network of servers close to visitors that keep copies).max-age=N: the stored copy counts as up to date forNseconds. With0, it's out of date at once, so the browser asks the server whether the file changed before using it. When it hasn't, the answer is short, but it's still a request.immutable: the content at this address never changes, so while the copy is up to date, the browser has no reason to ask.
Next.js can't know whether you'll replace public/logo.svg tomorrow under the same name, so it gives public files max-age=0. An imported file's hashed address changes when the file does, so the recorded leaf came with a year and immutable. In this version's source, the build's own files (here the leaf, and the page's JavaScript and CSS, all under /_next/static/) get that header with two exceptions: a file that already has a Cache-Control header keeps it, and service worker files under /_next/static/service-worker/ get public, max-age=0, must-revalidate. Pick a file below and read its header in the Network tab:
app/page.tsxexport default function Home() { return ( <main> <img src="/logo.svg" alt="Tea shop logo" width={120} height={80} /> <h1>Tea shop</h1> <p>Green tea, mint tea and lemon tea.</p> </main> );}GET /logo.svg200
- Response headers
Cache-Controlpublic, max-age=0Content-Typeimage/svg+xmlVaryAccept-Encoding
The body that arrived, as it arrived
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80" viewBox="0 0 120 80">
<rect x="20" y="16" width="60" height="48" rx="12" fill="#2f855a" />
<circle cx="88" cy="40" r="12" fill="none" stroke="#2f855a" stroke-width="6" />
</svg>
Line 4 points at /logo.svg, a file in public. It came with Cache-Control: public, max-age=0: browsers may store it, but must check with the server before using it again.
The second version sets a header of its own with headers() in next.config.ts. Lines 9 to 12 say: for the address /logo.svg, send Cache-Control: public, max-age=86400. The recording shows that header replaced Next.js's, so browsers may now use their copy for a day without asking. That's faster on the next visit, with a cost: change the logo, and some visitors see the old one for up to a day. When a public file needs a long cache, give each version a new file name, like logo-2.svg.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
async headers() {
return [
{
source: "/logo.svg",
headers: [{ key: "Cache-Control", value: "public, max-age=86400" }],
},
];
},
};
export default nextConfig;
headers() is an async function that returns a list of rules. Each rule has a source, the address it matches, and the headers to add, each a key and a value. Lines 5 and 6 are create-next-app's settings, kept as they were.
Each row is one file from the recordings, with the header it came with and what that header allows:
| File | Cache-Control | What it allows |
|---|---|---|
/logo.svg, from public | public, max-age=0 | Any cache may store it, but it's out of date at once. Before using it again, the browser asks the server whether it changed. |
/logo.svg, with a header in next.config.ts | public, max-age=86400 | Any cache may use it for a day (86,400 seconds) without asking. A changed logo can show up to a day late. |
| The imported leaf, at its hashed address | public, max-age=31536000, immutable | Any cache may use it for a year without asking. A changed leaf gets a new hashed address, so the old copy is never wrong. |
/icon.svg, from app/icon.svg | public, max-age=0, must-revalidate | Checked again before every use, like a file in public. The tag in the page adds a hash to its address. |
/logo.svg changed before it shows its copy. It shows the leaf from its copy without asking: for a year, nothing can change at that address.Favicons and app icons: a first look
The small picture in a browser tab, a bookmark or a search result is the site's icon, often called a favicon. In the App Router, the docs say to make it a metadata file: a file with a special name at the top of app: favicon.ico, which create-next-app puts there, or icon with an image ending, like icon.svg or icon.png. Here is the shop with app/icon.svg:
app/icon.svg<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"> <rect width="32" height="32" rx="8" fill="#2f855a" /> <path d="M8 24 C8 14 15 8 24 8 C24 17 18 24 8 24 Z" fill="#ffffff" /></svg>$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (4/4) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ ○ /icon.svg ○ (Static) prerendered as static contentThree things happened, and you wrote no code for them. The Terminal tab lists ○ /icon.svg: Next.js made a route that serves the file. In the Network tab, the home page's <head> has this tag, which tells the browser where the icon is:
<link rel="icon" href="/icon.svg?icon.3oojcfa4q0a79.svg" sizes="any" type="image/svg+xml">sizes="any" says an SVG can be drawn at any size, and type gives its kind. After the ? in href comes a hash. The docs say Next.js adds it so the icon can be cached well. And /icon.svg itself came with Cache-Control: public, max-age=0, must-revalidate: checked before every use, like a file in public.
Older projects put favicon.ico in public. Like any file there, it's served at its own address, /favicon.ico, and browsers ask for that address on their own. Next.js's docs still say to prefer the metadata files in app, which come with the tag. The Metadata API lesson shows the rest of the <head>, including icons written by hand in a layout's metadata. Icon files win over them. Icons made with code, Apple's home-screen icon and link-preview pictures are in the Metadata and SEO lessons of this stage.
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#2f855a" />
<path d="M8 24 C8 14 15 8 24 8 C24 17 18 24 8 24 Z" fill="#ffffff" />
</svg>
Where does each file go?
Ask one question about each file: does its address have to stay the same? If people or tools outside your code use the address, put the file in public. If only your own components use it, import it. If it's the site's icon or a file search engines read, use the special file in app.
- The logo in the shop's header
- A price list PDF that last month's emails link to
- A file a search engine asks you to put at
/google1234.htmlto prove you own the site - The small picture in the browser tab
- A photo of mint tea inside a product card component
- The
robots.txtfile that search engines read
Sort each file of the tea shop into the place it belongs.
Common mix-ups
- "Next.js caches files in
publicfor a long time." No. They come withpublic, max-age=0, so browsers check them again before every use. Only a header of your own changes that. - "An imported picture becomes part of the JavaScript." No. It stays a file of its own under address the build made, and the HTML points at it. The import only gives your code its address and size.
- "
immutablemeans the picture can never change." It means the content at that address never changes. Change the file, and the next build gives it a new address. - "The hash in a generated address is part of the API." No. Its format is the bundler's choice. Always use
leaf.src, never a name copied from a build. - "You can upload files into
publicwhile the app runs." Not reliably. The recorded version'snext startlists the folder when it starts, so a file added later answers 404 there. Uploads need storage made for them. - "A file in
publicis private unless a page links to it." No. Anyone who guesses or learns the address can download it.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
Line 4 shows public/logo.svg. Which Cache-Control header does the server send with /logo.svg?
export default function Home() {
return (
<main>
<img src="/logo.svg" alt="Tea shop logo" width={120} height={80} />
<h1>Tea shop</h1>
<p>Green tea, mint tea and lemon tea.</p>
</main>
);
}
public, max-age=0. The recorded /logo.svg came with exactly that header: any cache may store it, but must check with the server before using it again.
The logo doesn't show. Which status does the server answer for /public/logo.svg, and how do you fix line 4?
export default function Home() {
return (
<main>
<img src="/public/logo.svg" alt="Tea shop logo" width={120} height={80} />
<h1>Tea shop</h1>
<p>Green tea, mint tea and lemon tea.</p>
</main>
);
}
export default function Home() {
return (
<main>
<img src="/logo.svg" alt="Tea shop logo" width={120} height={80} />
<h1>Tea shop</h1>
<p>Green tea, mint tea and lemon tea.</p>
</main>
);
}
404. public is the top of the site, so the address is /logo.svg, as in line 4 of the fixed page. The recorded /public/logo.svg answered 404 with Next.js's not-found page.
Write app/about/page.tsx so it shows the leaf from app/leaf.svg. Does its address match the home page's leaf?
// app/about/page.tsx
export default function AboutPage() {
// Import the leaf from app/leaf.svg, show it, and print its address
}import leaf from "../leaf.svg";
export default function AboutPage() {
return (
<main>
<h1>About us</h1>
<img src={leaf.src} alt="A tea leaf" width={leaf.width} height={leaf.height} />
<p>The leaf is at {leaf.src}</p>
</main>
);
}
The same. The address comes from the file, not from the page that imports it. In the recording, / and /about both print the same generated address, and the build serves the file once.
Both pictures change from green to orange, and the shop is built again. Which address changes: /logo.svg, or the leaf's?
import leaf from "./leaf.svg";
export default function Home() {
return (
<main>
<img src="/logo.svg" alt="Tea shop logo" width={120} height={80} />
<h1>Tea shop</h1>
<img src={leaf.src} alt="A tea leaf" width={leaf.width} height={leaf.height} />
<p>The leaf is at {leaf.src}</p>
</main>
);
}
The leaf's. The imported leaf's hashed address changes when the file changes, so the recorded orange leaf got a new one. The logo stayed at /logo.svg, the address of its file in public.
Add a rule to next.config.ts so browsers may keep /logo.svg for a day without asking. Which header does it come with then?
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
// Add a Cache-Control header for /logo.svg here
};
export default nextConfig;
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
async headers() {
return [
{
source: "/logo.svg",
headers: [{ key: "Cache-Control", value: "public, max-age=86400" }],
},
];
},
};
export default nextConfig;
public, max-age=86400, the seconds in a day. The recorded /logo.svg came with that header instead of max-age=0. If the logo changes, give it a new file name, or some visitors see the old one for up to a day.
You save this SVG as app/icon.svg. Which kind of tag does Next.js add to every page's <head>?
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#2f855a" />
<path d="M8 24 C8 14 15 8 24 8 C24 17 18 24 8 24 Z" fill="#ffffff" />
</svg>
A <link rel="icon"> tag. Saved as app/icon.svg, the file got a route, ○ /icon.svg, and the recorded home page's <head> got <link rel="icon" href="/icon.svg?…" sizes="any" type="image/svg+xml">.
Check your understanding
For each question, ask where the file lives, and whether its address can change.
Question 1 of 7A file is saved at
public/teas/mint.svg. Which address does a page use for it?Choose an answer to see the explanation.
Question 2 of 7Why does Next.js send files in
publicwithCache-Control: public, max-age=0?Choose an answer to see the explanation.
Question 3 of 7This is the page. What does
leaf.srchold?Read the code, then predictTSXimport leaf from "./leaf.svg"; export default function Home() { return ( <main> <img src="/logo.svg" alt="Tea shop logo" width={120} height={80} /> <h1>Tea shop</h1> <img src={leaf.src} alt="A tea leaf" width={leaf.width} height={leaf.height} /> <p>The leaf is at {leaf.src}</p> </main> ); }Choose an answer to see the explanation.
Question 4 of 7Why may browsers keep an imported file for a year, with
immutable?Choose an answer to see the explanation.
Question 5 of 7With this next.config.ts, which Cache-Control header does
/logo.svgget?Read the code, then predictTypeScriptimport type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ cacheComponents: true, partialPrefetching: true, async headers() { return [ { source: "/logo.svg", headers: [{ key: "Cache-Control", value: "public, max-age=86400" }], }, ]; }, }; export default nextConfig;Choose an answer to see the explanation.
Question 6 of 7You add
app/icon.svg. What does Next.js do with it?Choose an answer to see the explanation.
Question 7 of 7The shop runs with
next startfrom Next.js 16.4.3, the recorded version. You copy a new file intopublic. What happens when someone asks for it?Choose an answer to see the explanation.
Key takeaways
- A file in
publicis served at its path inside the folder, as it is, to anyone.publicitself isn't part of the address. - Next.js sends
publicfiles withCache-Control: public, max-age=0: browsers check them again before every use, because the file may change under the same name. - An imported file gets a hashed address from the build, with
src,widthandheight. The address changes when the file changes, so the recorded leaf came withmax-age=31536000, immutable. - The name's exact format belongs to the build. Use
leaf.src; never copy a name from a build. headers()innext.config.tscan give apublicfile a longer cache. Then rename the file when it changes.- The site's icon is
app/favicon.icoorapp/icon.svg: Next.js serves it and adds the<link rel="icon">tag.
Remember the one-liner.
public keeps the address and checks every time; an import changes the name and keeps it for a year.
These links point at release v16.4.3, the version every example on this page was built with. The year-long header for files under /_next/static/ is set in lines 704 to 722 of router-server.ts, only when nothing else set a Cache-Control header first, and not for service worker files. The list of public files that next start makes when it starts is at lines 298 to 313 of filesystem.ts.
Coming next: The Metadata API. It writes the rest of each page's <head>: titles, descriptions and link previews, from a metadata object in a layout or a page.