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.

FileSizeUsed by
src/app/icon.svg32×32 viewBoxEvery modern browser tab. Scales free.
src/app/apple-icon.png180×180iOS home screen. No transparency — iOS adds a white box behind it.
src/app/favicon.ico32×32Only if you care about old Windows browsers. Optional.
<DH> 48
<DH> 32
<DH> 16

Monogram

Initials in mono, brackets in accent. Reads at 16px because there are only two glyphs.

SVG
src/app/icon.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">&lt;</tspan><tspan fill="#ffffff">DH</tspan><tspan fill="#818cf8">&gt;</tspan>
</text>
</svg>
48
32
16

Geometric

Two overlapping shapes, one outlined and one solid. No text, so it survives any size.

SVG
src/app/icon.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>
B48
B32
B16

Gradient fill

Single letter on the brand gradient. Highest contrast on a busy tab strip.

SVG
src/app/icon.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.

danielhadaway.dev

Anatomy: eyebrow (who/what) · gradient title · tagline · footer rule + domain. An accent glow bleeding off one corner stops it reading as a slide.

src/lib/og.tsx — one shared template
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,
  );
}
src/app/<route>/opengraph-image.tsx — one-liner per route
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.

src/app/layout.tsx
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 },
};
src/app/robots.ts
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`,
  };
}
viewport — theme colour matched to the palette
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.
🥚 0/3