Assets & metadata
Favicons, OG cards, head tags
The bits that live outside the page and get done last, badly. All of it is generated from the same content file as the site, so it can never drift out of sync.
Favicon set
In App Router these are files, not link tags — drop them in src/app/ and Next writes the markup. Three files is the whole set; anything more is 2013 cargo cult.
| File | Size | Used by |
|---|---|---|
| src/app/icon.svg | 32×32 viewBox | Every modern browser tab. Scales free. |
| src/app/apple-icon.png | 180×180 | iOS home screen. No transparency — iOS adds a white box behind it. |
| src/app/favicon.ico | 32×32 | Only if you care about old Windows browsers. Optional. |
Monogram
Initials in mono, brackets in accent. Reads at 16px because there are only two glyphs.
SVG
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="7" fill="#0d0f14"/>
<text x="16" y="16.5" text-anchor="middle" dominant-baseline="central"
textLength="26" lengthAdjust="spacingAndGlyphs"
font-family="ui-monospace, Menlo, monospace" font-size="16" font-weight="700">
<tspan fill="#818cf8"><</tspan><tspan fill="#ffffff">DH</tspan><tspan fill="#818cf8">></tspan>
</text>
</svg>Geometric
Two overlapping shapes, one outlined and one solid. No text, so it survives any size.
SVG
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="7" fill="#0d0f14"/>
<rect x="7" y="7" width="11" height="11" rx="2" fill="none" stroke="#818cf8" stroke-width="2"/>
<rect x="15" y="15" width="10" height="10" rx="2" fill="#22d3ee"/>
</svg>Gradient fill
Single letter on the brand gradient. Highest contrast on a busy tab strip.
SVG
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#818cf8"/><stop offset="1" stop-color="#22d3ee"/>
</linearGradient></defs>
<rect width="32" height="32" rx="7" fill="url(#g)"/>
<text x="16" y="17" text-anchor="middle" dominant-baseline="central"
font-family="system-ui, sans-serif" font-size="19" font-weight="800" fill="#07080b">B</text>
</svg>Favicon rules
- Check it at 16px, on both a light and a dark browser tab strip.
- Give it its own rounded background rect — a transparent glyph disappears against half the themes out there.
- Two glyphs maximum if you use text. Three is illegible at tab size.
- Generate the 180×180 apple icon from the SVG: rsvg-convert -w 180 -h 180 icon.svg > apple-icon.png
OG images
1200×630. Generate them with next/og from the same content file the page reads, so they can never say something the page doesn't. Rendered to static PNGs at build time — works under output: export.
Daniel Hadaway · Case study
Project name
The one-line tagline, read straight out of the same content file as the page.
Anatomy: eyebrow (who/what) · gradient title · tagline · footer rule + domain. An accent glow bleeding off one corner stops it reading as a slide.
import { ImageResponse } from "next/og";
import { site, projects } from "@/content/site";
export const OG_SIZE = { width: 1200, height: 630 };
export function ogImageForPath(path: string) {
const p = projects.find((x) => x.deepDive === path);
if (!p) throw new Error(`No project for OG path ${path}`);
const [a, b] = p.accent;
return new ImageResponse(
(
<div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column",
justifyContent: "space-between", background: "#07080b",
padding: "80px", fontFamily: "sans-serif" }}>
<div style={{ position: "absolute", top: -200, left: -120, width: 600, height: 600,
borderRadius: "50%",
background: `radial-gradient(circle, ${a}55 0%, transparent 70%)` }} />
<div style={{ display: "flex", fontSize: 30, color: "#9aa0ac" }}>
{site.name} · Case study
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<div style={{ display: "flex", fontSize: 84, fontWeight: 700, lineHeight: 1.05,
backgroundImage: `linear-gradient(90deg, ${a}, ${b})`,
backgroundClip: "text", color: "transparent" }}>{p.name}</div>
<div style={{ display: "flex", fontSize: 38, color: "#c3c7d1", maxWidth: 900 }}>
{p.tagline}
</div>
</div>
</div>
),
OG_SIZE,
);
}import { ogImageForPath, OG_SIZE } from "@/lib/og";
export const dynamic = "force-static"; // required under output: "export"
export const size = OG_SIZE;
export const contentType = "image/png";
export const alt = "Project name — case study";
export default function Image() {
return ogImageForPath("/project-name");
}OG gotchas that cost me an afternoon each
- display: flex on every div. Satori has no block layout — a plain div with two children silently renders wrong.
- No gap shorthand quirks, no CSS variables, no external stylesheets. Inline styles only.
- Set metadataBase or the image URL ships relative and no crawler resolves it.
- Don't set twitter.images if you want per-route cards — let it fall back to openGraph so each page inherits its own.
- Keep the title under ~60 characters. It gets cropped in the WhatsApp preview, which is where most links actually get opened.
- Test in a real chat window, not a validator. Validators lie about crawler caching.
Metadata boilerplate
Root layout. Everything derived from one content file so a copy change propagates to head tags, OG cards, sitemap and structured data at once.
export const metadata: Metadata = {
metadataBase: new URL(site.url),
title: { default: `${site.name} — ${site.role}`, template: `%s — ${site.name}` },
description,
authors: [{ name: site.name, url: site.url }],
creator: site.name,
alternates: { canonical: site.url, types: { "application/rss+xml": "/feed.xml" } },
openGraph: {
type: "website", url: site.url, siteName: site.name,
title: `${site.name} — ${site.role}`, description,
images: [{ url: "/og.png", width: 1200, height: 630, alt: site.name }],
},
twitter: { card: "summary_large_image", title: `${site.name}`, description },
robots: { index: true, follow: true },
};import type { MetadataRoute } from "next";
import { site } from "@/content/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
sitemap: `${site.url}/sitemap.xml`,
};
}export const viewport: Viewport = {
themeColor: "#07080b", // address bar on mobile; a mismatch here is very visible
colorScheme: "dark", // makes native form controls and scrollbars match
};Public folder
What actually earns a place in /public.
- og.png — the site-wide 1200×630 fallback, for pages without a generated card.
- humans.txt — free, and the kind of thing the people you want to be found by go looking for.
- Real files only. Anything derivable from content should be generated at build time instead, or it will drift.