Robots, sitemaps, icons, viewport
Build the site-level SEO and platform bundle for a SaaS with Next.js file conventions, robots, sitemap, icons, viewport, and the web manifest.
The Acme app renders. Every page carries its title, its description, and its social card, all of which you wired up in the last lesson, Metadata and dynamic OG cards. So you ship it to a staging URL, run a pre-launch SEO audit, and the report comes back with a list of gaps that have nothing to do with any single page:
- No
robots.txt. Crawlers have no instructions. - No
sitemap.xml. Crawlers can still follow links, but nothing tells them which URLs matter or when they last changed. - The browser tab shows the default Vercel favicon, not the Acme mark.
- Add the app to an iPhone home screen and you get a blurry screenshot, not an icon.
- The mobile address bar is plain grey instead of the brand color.
- The one that can hurt you: the staging deploy is fully indexable. Google is about to put your unfinished app in its index, where it competes with production for the same keywords.
None of these are page-level concerns. They are site-level artifacts: the handful of standards-mandated files that a crawler, an operating system, or a mobile browser expects to find near the root of your site, at well-known paths, regardless of which page someone is on. The old web answered each one by hand-maintaining a file in a static folder. You dropped a robots.txt in one place, generated a sitemap.xml with a script in another, exported a favicon at ten sizes, and pasted ten <link> tags into <head>. Every one of those was a file you had to remember to update by hand.
Next.js replaces all of it with file conventions: typed functions you write near the root of app/. The platform discovers each one by filename, runs it at build time, caches the result, and either serves it at the path the standard expects or wires it into <head> for you. That single idea runs through the whole lesson, so it is worth stating once before you hang each section on it.
By the end you will be able to stand up the complete root SEO and platform bundle for a new SaaS: the marketing crawl-control files, the icon set, the install manifest, and the mobile viewport. You will know which filename owns which output, and you will carry the reflex to keep each one pure so the platform can cache it. We will build it up file by file and assemble the whole thing into one concrete app/ directory at the end.
The file-convention map
Section titled “The file-convention map”Start with the map rather than any single file. Every file in this lesson shares the same four properties: it is discovered by its filename, it returns a typed value (or is a typed image), it runs as a cached route handler, and it is auto-wired into <head> or served at a well-known URL. The only thing that changes from file to file is which artifact it owns. So the most useful thing to hold in your head is not the syntax of any one file but the mapping from filename to output.
Here is that mapping. On the left are the files as they sit in app/; on the right is what the platform emits for each one.
Directoryapp/
- layout.tsx
export const metadata+export const viewport - robots.ts
- sitemap.ts
- favicon.ico
- icon.png
- apple-icon.png
- manifest.ts
- opengraph-image.png
- layout.tsx
app/ robots.ts /robots.txt sitemap.ts /sitemap.xml favicon.ico <link rel="icon" href="/favicon.ico" sizes="any"> icon.png <link rel="icon" href="/icon?…">— size + type inferred apple-icon.png <link rel="apple-touch-icon" href="/apple-icon?…"> manifest.ts <link rel="manifest">+ served at/manifest.webmanifest opengraph-image.png og:image recap export const viewport <meta name="viewport">+<meta name="theme-color"> Each filename owns one site-level artifact. The platform discovers the file, runs it at build, caches the result, and either serves it at a standard path or injects the head tag. No hand-edited <head>, no files dropped in /public.
That table is the table of contents for the rest of the lesson. We will walk it roughly top to bottom: the two crawl-control files, then the icons, then the viewport export, then the manifest. After that, one short section names the property they all share, cached by default, and a final section assembles everything into the real app/ directory.
robots.ts: telling crawlers where to go
Section titled “robots.ts: telling crawlers where to go”A crawler arriving at app.acme.com looks first for /robots.txt: a plain-text file that tells it which paths it may fetch and where to find your sitemap. That file follows the Robots Exclusion Standard , and in Next.js you no longer hand-write it. You write app/robots.ts, a default-exported function that returns a typed Robots object, and the platform serves the rendered text at /robots.txt.
The minimal shape is small. You declare rules for which user-agents may crawl what, and you point at your sitemap and canonical host:
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: '*', allow: '/', disallow: '/api' }, sitemap: 'https://app.acme.com/sitemap.xml', host: 'https://app.acme.com', };}That is the entire object. userAgent: '*' matches every crawler, allow: '/' opens the whole site, disallow: '/api' keeps bots out of your route handlers, and sitemap and host hand the crawler your map and your canonical origin. The shape is typed: MetadataRoute.Robots autocompletes every field, so a misspelled dissallow is a red squiggle rather than a line your text file silently ignores.
Note the export default. Everywhere else the course uses named exports, but these file conventions are the carve-out, because the framework finds them by filename and expects a default export, exactly like layout.tsx, page.tsx, and last lesson’s opengraph-image.tsx. Every file in this lesson follows that same framework-dictated shape.
The shape is mechanical. The decision worth your attention in this file is the branch rather than the shape: a preview deploy must not be indexable. This is the gap the audit flagged, and because it causes a real production incident rather than being a style preference, it is worth going into.
The failure works like this. Every push to a feature branch gets its own Vercel preview URL, a full production build at a public address. If your robots.ts returns allow: '/' unconditionally, that preview is fully crawlable. Google indexes your half-finished pages, and worse, those preview URLs now compete with your real domain for the same keywords. That is duplicate content : search engines see two copies of your site and can rank the staging one above production.
To prevent that, robots.ts reads which environment it is running in and branches accordingly:
import type { MetadataRoute } from 'next';
const ORIGIN = 'https://app.acme.com';
export default function robots(): MetadataRoute.Robots { if (process.env.VERCEL_ENV !== 'production') { return { rules: { userAgent: '*', disallow: '/' } }; }
return { rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/dashboard/'] }, sitemap: `${ORIGIN}/sitemap.xml`, host: ORIGIN, };}The whole file turns on this one line. process.env.VERCEL_ENV is set by Vercel to 'production', 'preview', or 'development'. Every environment that is not production takes the early return.
import type { MetadataRoute } from 'next';
const ORIGIN = 'https://app.acme.com';
export default function robots(): MetadataRoute.Robots { if (process.env.VERCEL_ENV !== 'production') { return { rules: { userAgent: '*', disallow: '/' } }; }
return { rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/dashboard/'] }, sitemap: `${ORIGIN}/sitemap.xml`, host: ORIGIN, };}For any non-production build, return one rule: disallow: '/'. That blocks every crawler from every path, with no sitemap, no host, and nothing to index. Your previews stay invisible.
import type { MetadataRoute } from 'next';
const ORIGIN = 'https://app.acme.com';
export default function robots(): MetadataRoute.Robots { if (process.env.VERCEL_ENV !== 'production') { return { rules: { userAgent: '*', disallow: '/' } }; }
return { rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/dashboard/'] }, sitemap: `${ORIGIN}/sitemap.xml`, host: ORIGIN, };}Only production reaches the real rules. allow: '/' opens the public surface, and disallow carves out the paths bots should never touch: the API and the signed-in dashboard.
import type { MetadataRoute } from 'next';
const ORIGIN = 'https://app.acme.com';
export default function robots(): MetadataRoute.Robots { if (process.env.VERCEL_ENV !== 'production') { return { rules: { userAgent: '*', disallow: '/' } }; }
return { rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/dashboard/'] }, sitemap: `${ORIGIN}/sitemap.xml`, host: ORIGIN, };}sitemap and host point at the absolute production origin. Hard-code that canonical origin rather than deriving it from the incoming request host. On a preview deploy the request host is the preview URL, and you never want to tell a crawler that the canonical site lives at a throwaway address.
The discriminator on line 6 is the part to get right, because it is the one place people slip. The obvious instinct is to check process.env.NODE_ENV === 'production', but that does not work: on Vercel a preview deploy is a production build, so NODE_ENV is 'production' on previews too. Gating on NODE_ENV would leave every preview indexable, the exact bug you are trying to prevent. The signal that actually distinguishes the three environments is VERCEL_ENV. Reading process.env directly like this works, but it is stringly-typed and easy to mistype, so a later chapter wraps environment variables in a validated, typed env object, and this read is one of the first things to move there.
The stakes cut the other way too, which is why the discriminator is worth this much care. Ship disallow: '/' to production and you have told Google to delist your entire site. Nothing breaks and nothing errors: the build is green and the page renders. Then weeks later organic traffic quietly craters and someone goes looking for why. That combination, invisible and slow to surface, makes it one of the hardest failures to catch, and the whole outcome depends on getting this one branch right.
sitemap.ts: the index of what to crawl
Section titled “sitemap.ts: the index of what to crawl”robots.txt tells crawlers where they may go; the sitemap tells them where to look. It is an XML file listing the URLs you want indexed, with hints about when each last changed. You write app/sitemap.ts, return an array of entries, and Next.js emits valid sitemap XML at /sitemap.xml.
The shape is an array, one entry per URL:
import type { MetadataRoute } from 'next';
const ORIGIN = 'https://app.acme.com';
export default function sitemap(): MetadataRoute.Sitemap { return [ { url: `${ORIGIN}/`, changeFrequency: 'weekly', priority: 1 }, { url: `${ORIGIN}/pricing`, changeFrequency: 'monthly', priority: 0.8 }, { url: `${ORIGIN}/blog`, changeFrequency: 'daily', priority: 0.6 }, { url: `${ORIGIN}/help`, changeFrequency: 'weekly', priority: 0.5 }, ];}Each entry needs an absolute url, because relative paths are invalid in a sitemap. changeFrequency and priority are hints, not commands, and crawlers weigh them loosely. lastModified is the field that earns its keep, and it is missing here on purpose: for static marketing pages there is nothing meaningful to put in it. The moment your content is data-driven, that changes.
A real SaaS is data-driven. Your public surface is not just four hand-listed marketing routes. It is every published blog post, every public help article, and every public profile, and that set grows every time someone hits publish. Hard-coding those URLs means the sitemap is stale the moment new content ships. So the function goes async and queries the indexable rows instead:
export default function sitemap(): MetadataRoute.Sitemap { return MARKETING_ROUTES.map((path) => ({ url: `${ORIGIN}${path}`, changeFrequency: 'weekly', }));}Fine until content ships. Four routes, known at build, never out of date, because they never change. A blog or help center breaks this the first time someone publishes.
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const articles = await listPublicHelpArticles();
const staticEntries = MARKETING_ROUTES.map((path) => ({ url: `${ORIGIN}${path}`, changeFrequency: 'weekly' as const, }));
const articleEntries = articles.map((article) => ({ url: `${ORIGIN}/help/${article.slug}`, lastModified: article.updatedAt, changeFrequency: 'monthly' as const, }));
return [...staticEntries, ...articleEntries];}Always current. The function awaits a read of the published help articles and maps each row to an entry. lastModified is the row’s real updatedAt, so editing an article tells crawlers to recrawl it. Static and dynamic entries concatenate into one array.
Two things about that database version matter beyond the syntax. First, listPublicHelpArticles() is a read helper from your data layer, not an inline query. The course keeps every database read behind a verb-named function in db/queries/, so the call site stays this clean and the query lives in one place.
Second, the rule that keeps a sitemap correct: only publicly indexable rows belong in it. That means no authenticated data, no tenant-scoped data, nothing behind a login. Putting a private invoice URL in your sitemap leaks the fact that it exists, and it is useless anyway, since a crawler can’t fetch a page it isn’t allowed to reach.
One property ties this file back to the rest of the chapter. sitemap.ts is a special route handler, and under Cache Components it is static by default: the build renders it once, and the CDN serves that one artifact to every crawler. That is exactly what you want, because a crawl becomes a cache hit with zero function invocations. It is fragile in a specific way, though.
This is the same rule from earlier in the chapter, static by default unless a request-time read opts you out, applied to a file you might not have thought of as a render. You will see it again in a moment as the rule that governs every file in this lesson.
One scale note, worth knowing so you recognize it later. Google rejects a single sitemap over 50,000 URLs or 50 MB. If you genuinely have that many indexable pages, the generateSitemaps function splits the output into numbered files, /sitemap/0.xml and /sitemap/1.xml, plus an index that points at them. Most SaaS apps never come close, so reach for it only if you actually cross 50,000 URLs.
Icons: favicon, icon, and apple-icon
Section titled “Icons: favicon, icon, and apple-icon”A favicon is the small mark in the browser tab. The same idea now extends across devices: a high-resolution icon for the tab, a larger one for Android and PWA installs, and a dedicated tile for the iOS home screen. In the past you exported every size, named them by hand, and pasted a wall of <link> tags into <head>. Next.js collapses all of it into three file conventions: you drop image files in app/ with reserved names, and the platform fingerprints them, caches them, and injects the right <link> tags.
There are three filenames, one job each:
favicon.ico .ico only <link rel="icon" href="/favicon.ico" sizes="any"> icon.* .ico.jpg.jpeg.png.svg <link rel="icon"> type + sizes inferred from the file apple-icon.* .jpg.jpeg.png (raster only) <link rel="apple-touch-icon"> favicon.ico lives only at the root of app/. icon and apple-icon can sit in any route segment to override per-section. Apple touch icons must be raster: no SVG, no .ico.
The plain path is also the right one for almost every app: drop a static image in app/ and you are done. The platform reads the file, hashes its contents for cache-busting, caches it on the CDN, infers the type and sizes from the actual bytes, and injects the <link> tag. You get no manual <head>, no manual fingerprinting, and no stale icon after a deploy. Reach for anything fancier only when you have a real reason.
Real devices do want more than one size, though. A 32-pixel tab icon and a 512-pixel install icon are different files. You set multiple icons with a numeric suffix on the filename: icon.png, icon1.png, icon2.png, and so on, sorted lexically. The filenames carry no dimension information, so the platform reads each file’s real pixel size and infers the sizes attribute from the bytes themselves. The practical set for a SaaS covers the common targets: a high-resolution icon (around 512) for crisp tabs and PWA splash, a mid-size icon (192) for Android and home-screen installs, an apple-icon for the iOS tile, and the legacy favicon.ico for the broadest browser support.
Here is what that looks like in app/:
Directoryapp/
- favicon.ico legacy single-file favicon, broadest browser support
- icon.png high-res mark,
512×512, crisp tabs and PWA splash - icon1.png mid-size mark,
192×192, Android / PWA installs - apple-icon.png iOS home-screen tile, raster only
One device note here comes from iOS, not from Next.js. The iOS home screen scales your apple-icon up to fill its tile, and an undersized source renders blurry, because Apple’s tiles expect something generously sized, around 180×180 or larger. Next.js will not reject a small file, so the only sign of trouble is a blurry icon on the phone. Ship a comfortably large raster apple touch icon and you avoid it. Remember the format constraint from the table too: an apple-icon must be raster, png or jpg, because Apple’s tiles don’t take SVG.
You can also generate an icon instead of shipping a static file: name it icon.tsx or apple-icon.tsx and default-export a function that returns an ImageResponse, the same generated-image API you met in the last lesson for OG cards. The cases for this are narrow and rare, such as per-organization branded favicons or an A/B-tested mark. Know it exists, and reach for the static file for almost everything.
The same applies to the social-card images from the last lesson. A root-level opengraph-image.png, paired with opengraph-image.alt.txt for its alt text, is the brand-wide fallback every route inherits unless it overrides. That root brand fallback is the piece the last lesson deferred to here, because it belongs in this site-level bundle. The one thing to watch is to keep that fallback root-only. If you drop opengraph-image files into several nested layouts, they collide with no error telling you which one won. One brand fallback at the root, with per-page overrides where a page needs them, is the whole rule. Everything about generating those images lives in the last lesson; this section is only about where the brand fallback sits in the bundle.
The PWA comes up in the manifest section, where we will define it properly. For now, the icon set you just declared is exactly what the install manifest will point at.
The viewport export: the field that isn’t metadata
Section titled “The viewport export: the field that isn’t metadata”One field in this whole area trips up nearly everyone, because it looks like metadata and isn’t. It controls how your page renders on mobile and what color tints the browser chrome, and if you put it where it looks like it belongs, inside your metadata object, you get a build warning. So here is the rule first:
Viewport-affecting fields go in a separate export const viewport, never inside metadata.
The fields in question are width, initialScale, themeColor, and colorScheme. The reason for the split is what makes the rule stick. The metadata object owns the SEO and social tags: title, description, Open Graph, and canonical. Those describe your page to crawlers and link unfurlers. But width and themeColor drive a different family of <meta> tags, viewport, theme-color, and color-scheme, that control how the browser physically renders the page on a device. Because those serve a different purpose and have a different lifecycle, Next.js split them into their own typed export rather than letting them ride along in metadata. Here is the canonical shape:
import type { Viewport } from 'next';
export const viewport: Viewport = { width: 'device-width', initialScale: 1, themeColor: '#0f172a', colorScheme: 'light dark',};Each field does one job. width: 'device-width' with initialScale: 1 is the viewport meta default that makes a layout render at the phone’s real width instead of zooming out a desktop page; the scaffold ships it, and you should not fight it. colorScheme: 'light dark' declares that the app supports both color schemes. And themeColor is the theme-color : it tints the mobile browser’s chrome, the Safari and Chrome address bar, to match your brand surface instead of leaving it default grey. That last one is the gap the audit flagged.
themeColor carries the one real decision in this section. A single color string tints the chrome the same way regardless of theme. But a SaaS with both a light and a dark theme wants the address bar to match the active theme: dark chrome in dark mode, light chrome in light mode. For that, themeColor takes an array of { media, color } entries, each keyed on a color-scheme media query:
export const viewport: Viewport = { width: 'device-width', initialScale: 1, colorScheme: 'light dark', themeColor: [ { media: '(prefers-color-scheme: light)', color: '#ffffff' }, { media: '(prefers-color-scheme: dark)', color: '#0f172a' }, ],};The single string is the simple default; the array is the better pick for a theme-switching app, so the chrome never clashes with the surface beneath it.
The mistake here is easy to make and easy to miss. Putting a viewport field inside metadata does not error: the build still succeeds, and the tag may even still emit. It only warns, and the warning is easy to scroll past. But this is the deprecated path, deprecated since Next 14 and still flagged through 16, and it will eventually break. The fix is to move the field out. Seeing the two shapes side by side makes the change clear:
export const metadata: Metadata = { title: 'Acme', description: 'Invoicing for small teams', themeColor: '#0f172a', viewport: { width: 'device-width', initialScale: 1 },};Deprecated, silently. themeColor and viewport inside metadata trigger a build warning, not an error. The page still builds, so it’s easy to miss. This shape is on the way out.
export const metadata: Metadata = { title: 'Acme', description: 'Invoicing for small teams',};
export const viewport: Viewport = { width: 'device-width', initialScale: 1, themeColor: '#0f172a',};Split into two exports. SEO and social fields stay in metadata; viewport and theme-color move to their own viewport export. Same layout.tsx, two sibling exports, and the warning is gone.
Two sibling exports in the same layout.tsx is the shape you want, and it is where the viewport export lands next to the metadata export the last lesson set up.
There is a generateViewport function, the dynamic counterpart to generateMetadata, for the rare case of a route-dependent viewport, such as a per-locale colorScheme. It takes the same Promise-shaped params you saw with generateMetadata. But it carries a sharper constraint than its metadata sibling, and that constraint is why you should almost never reach for it: viewport cannot be streamed. It affects the initial paint, so a generateViewport that reads runtime data blocks the entire route. There is no static shell, and nothing renders until the read resolves. Metadata can stream in late, but viewport can’t, which makes “keep viewport static” a stronger rule than “keep metadata static.” For the common case, the static viewport object is the only right answer.
The web manifest: add to home screen
Section titled “The web manifest: add to home screen”The last gap is the home-screen experience. When someone taps “Add to Home Screen” on the Acme app, the operating system wants a name, an icon, and a color to build the launcher tile and splash screen. That information lives in a web app manifest , and like everything else in this lesson it has a file convention: app/manifest.ts, a default-exported function that returns a typed manifest object. The platform serves it at /manifest.webmanifest and injects <link rel="manifest">.
A minimal manifest reuses the brand strings and the icon set you already declared:
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest { return { name: 'Acme — Invoicing for small teams', short_name: 'Acme', description: 'Send and track invoices.', start_url: '/', display: 'standalone', background_color: '#0f172a', theme_color: '#0f172a', icons: [ { src: '/icon.png', sizes: '512x512', type: 'image/png' }, { src: '/icon1.png', sizes: '192x192', type: 'image/png' }, ], };}A basic manifest costs almost nothing and unlocks real value: the app becomes installable, with a home-screen icon, a standalone display mode that drops the browser chrome, and splash colors that match your brand. The icons array points at the 192 and 512 marks you already shipped, the two sizes installers reach for.
Be clear about the boundary, though, so you don’t over-scope it. A manifest is install metadata, not a Progressive Web App. A full PWA, with a service worker, offline support, and push notifications, is a much deeper topic and out of scope here. The manifest gives you “Add to Home Screen” and a branded launcher; it does not make your app work offline. Knowing where that line sits lets you take the cheap win and stop there.
A static public/manifest.webmanifest is the alternative, and the .ts convention wins for the same reasons every other file in this lesson does: it is typed, and it can pull brand strings and the icon list from one shared config instead of duplicating them across a hand-maintained JSON file.
These are cached route handlers: keep them pure
Section titled “These are cached route handlers: keep them pure”Step back to the property they all share, which carries further than any one file. Every file you just met, robots.ts, sitemap.ts, icon.tsx, apple-icon.tsx, manifest.ts, and opengraph-image.tsx, is a special route handler. Under Cache Components, all of them are statically generated at build and served from the CDN by default, with zero per-request work. A crawler hitting /sitemap.xml, a browser fetching /icon.png, a phone reading /manifest.webmanifest: every one of those is a cache hit on a file the build produced once.
They flip to dynamic, re-running per request, only if you make them. The trigger is the same one from the rest of the chapter: read a request-time API (cookies(), headers(), an uncached external fetch) or set a dynamic route-segment config, and the platform has to re-render the file on every request, because its output now depends on the request. The whole value, one cached artifact served forever, collapses the moment a request-time read sneaks in. A sitemap.ts that calls cookies() runs its database query on every crawl, and a robots.ts that reads a header recomputes per request. You saw this rule with the cache model earlier in the chapter and with OG-image caching last lesson, and it applies to every file here. Viewport is the one place it applies harder, not softer.
It is harder because viewport can’t be streamed: it gates the initial paint, so a runtime-reading generateViewport blocks the entire route with no static shell at all. A dynamic sitemap only costs an invocation per crawl, but a dynamic viewport costs your whole page its first paint. If you genuinely need external (not request-time) data in a viewport, the escape hatch is to cache that read inside the function. The honest answer, though, is almost always to keep viewport a static object and not reach for the dynamic form.
Try it yourself. Sort each of these into whether it stays static, built once and served from the CDN, or flips dynamic, running on every request:
Sort each special file by whether the platform can cache it at build, or whether it re-runs on every request. Drag each item into the bucket it belongs to, then press Check.
sitemap.ts returning a hardcoded array of routesrobots.ts branching on process.env.VERCEL_ENVapple-icon.png filesitemap.ts that calls cookies()icon.tsx that reads headers()opengraph-image.tsx that fetches uncached data per requestThe distinction the exercise draws is the one to carry out of this lesson: reading the environment happens at build time and stays static, which is why the env-aware robots.ts is still a cached file, but reading the request, whether cookies, headers, or live data, is what opts you out.
One practical corollary applies to the generated-image files specifically. A dynamically generated OG card or icon, one that renders through ImageResponse, is cold on its first request: the render takes a few hundred milliseconds the first time, before the result is cached. If a bot scrapes that URL before anyone has warmed it, the bot waits. The fix is a post-deploy hook that pings your key OG URLs to warm the CDN before traffic arrives. Static image files never need this, since they are already bytes on disk. This applies only to the ImageResponse-generated files from the last lesson, so keep it in mind for when you reach for dynamic generation.
Assembling the root SEO bundle
Section titled “Assembling the root SEO bundle”You have built every piece. Here they are together: the complete root bundle for Acme, as one concrete app/ directory you could lift straight into a real project. It is the map from the start of the lesson with every row filled in:
Directoryapp/
- layout.tsx
export const metadata(last lesson) +export const viewport(this lesson) - robots.ts env-aware crawl control, blocks indexing off production
- sitemap.ts marketing routes + public help articles
- favicon.ico legacy favicon, broadest browser support
- icon.png high-res mark,
512×512 - icon1.png mid mark,
192×192, Android / PWA installs - apple-icon.png iOS home-screen tile, raster only
- manifest.ts install metadata, reuses the brand strings + icons
- opengraph-image.png brand social-card fallback (last lesson)
- opengraph-image.alt.txt alt text for the fallback card
- layout.tsx
The map from the start of the lesson, filled in. One layout.tsx carries both the metadata and viewport exports; every other file owns a single site-level artifact. All ten are typed, code-generated, cached by default, and discovered by filename, with no hand-edited <head> and nothing dropped in /public.
And here are the files that carry logic, grouped so you can see the whole bundle as coordinated parts. Each tab is a file you already wrote in this lesson, nothing new, just assembled in one place, with the one decision each file embodies called out below it:
import type { MetadataRoute } from 'next';
const ORIGIN = 'https://app.acme.com';
export default function robots(): MetadataRoute.Robots { if (process.env.VERCEL_ENV !== 'production') { return { rules: { userAgent: '*', disallow: '/' } }; }
return { rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/dashboard/'] }, sitemap: `${ORIGIN}/sitemap.xml`, host: ORIGIN, };}The one decision: gate indexing on VERCEL_ENV so previews never leak into the index.
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const articles = await listPublicHelpArticles();
const staticEntries = MARKETING_ROUTES.map((path) => ({ url: `${ORIGIN}${path}`, changeFrequency: 'weekly' as const, }));
const articleEntries = articles.map((article) => ({ url: `${ORIGIN}/help/${article.slug}`, lastModified: article.updatedAt, changeFrequency: 'monthly' as const, }));
return [...staticEntries, ...articleEntries];}The one decision: public, indexable rows only, never authenticated or tenant-scoped data.
import type { Metadata, Viewport } from 'next';
export const metadata: Metadata = { metadataBase: new URL('https://app.acme.com'), title: { default: 'Acme', template: '%s — Acme' }, description: 'Invoicing for small teams',};
export const viewport: Viewport = { width: 'device-width', initialScale: 1, themeColor: '#0f172a', colorScheme: 'light dark',};The one decision: SEO fields in metadata, viewport and theme-color in their own viewport export, the two siblings side by side. The metadata export’s metadataBase and title template are from last lesson, shown here as already-wired context.
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest { return { name: 'Acme — Invoicing for small teams', short_name: 'Acme', description: 'Send and track invoices.', start_url: '/', display: 'standalone', background_color: '#0f172a', theme_color: '#0f172a', icons: [ { src: '/icon.png', sizes: '512x512', type: 'image/png' }, { src: '/icon1.png', sizes: '192x192', type: 'image/png' }, ], };}The one decision: minimal install metadata reusing the brand strings and the 192/512 icons, installable but not a full PWA.
Hold the concrete bundle against the four properties from the start. Every file in that directory is typed: each returns an autocompleted, type-checked object or is a recognized image. Every one is code-generated: a function, not a hand-maintained artifact. Every one is cached by default: built once, served from the edge. And every one is discovered by filename: you never registered a route or hand-edited <head>. The old web’s pile of hand-maintained /public files, each one easy to forget to update, becomes a typed, environment-aware, CDN-served surface that the platform wires up for you. Pair this site-level map with the per-page metadata mechanisms from the last lesson and you have the complete SEO and platform surface for a new SaaS.
External resources
Section titled “External resources”This is a reference-heavy surface, full of exact filenames, supported formats, and object field names, the kind of thing you look up rather than memorize. These are the canonical Next.js pages to return to.
The index of every metadata file convention — the authoritative map for this whole lesson.
The Sitemap object shape, generateSitemaps, and the 50,000-URL limit.
The Robots object — rules, sitemap, host.
Supported formats, the numeric-suffix convention for multiple sizes, and dynamic generation.
The viewport export and its dynamic counterpart, including the no-streaming constraint.
Google's own take on the standard your robots.ts emits — including why a disallow is a request, not a lock.
The platform-neutral spec behind manifest.ts — every member, display modes, and theme_color.