webdev.complete
📦 Caching, Metadata, Images, Fonts
🚀Next.js & T3
Lesson 96 of 117
25 min

Images, Fonts, Metadata

next/image, next/font, generateMetadata, OG images.

Three problems Next.js solves so quietly you might miss them: images that are correctly sized and modern-format encoded, fonts that don't flash, and metadata (title, OG image, twitter card) that's rendered per route by a typed API. Each of these is a fight in raw HTML. In Next they take a single import.

next/image: do less, get more

The standard <img>tag has been doing the wrong thing for fifteen years. It doesn't pick the right size for the viewport, doesn't serve AVIF or WebP, and ships layout-shifting placeholders. next/image fixes all of that with one component.

app/page.tsx
import Image from "next/image";
import hero from "./hero.jpg";

export default function Home() {
  return (
    <Image
      src={hero}
      alt="A misty mountain at dawn"
      placeholder="blur"     // automatic blur-up from the static import
      priority               // skip lazy-load for the LCP image
    />
  );
}

What you actually get from that one tag:

  • A responsive srcset with the correct sizes for the viewport.
  • AVIF or WebP if the browser supports it, with JPEG/PNG fallback.
  • Width/height baked in so the layout reserves space (no shift, no jank).
  • Lazy loading off-screen images by default.
Static imports vs remote URLs
Static imports (import img from "./photo.jpg") give Next the file at build time, so it knows the dimensions and can pre-generate the blur placeholder. Remote URLs work too, but you must declare allowed domains in next.config.ts and pass width/height manually.
Remote image
<Image
  src="https://cdn.example.com/cover.jpg"
  alt="Cover art"
  width={1200}
  height={630}
  sizes="(max-width: 768px) 100vw, 50vw"
/>
next.config.ts
export default {
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "cdn.example.com" },
    ],
  },
};

The sizes prop matters more than people realize

Without sizes, the browser assumes the image will be 100vw and downloads the largest variant. Telling Next how the image is actually laid out shrinks the request dramatically on big screens.

next/font: zero layout shift, no network round trip

Web fonts traditionally fetch from a CDN, swap in late, and cause layout shift. next/font downloads the font at build time, inlines a fallback metric to prevent shift, and serves the file from your own domain (better privacy, better caching).

app/layout.tsx
import { Geist, Geist_Mono } from "next/font/google";

const sans = Geist({
  subsets: ["latin"],
  variable: "--font-sans",
});

const mono = Geist_Mono({
  subsets: ["latin"],
  variable: "--font-mono",
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${sans.variable} ${mono.variable}`}>
      <body>{children}</body>
    </html>
  );
}

Now --font-sans and --font-mono are CSS variables you can use anywhere:

app/globals.css
body { font-family: var(--font-sans); }
code, pre { font-family: var(--font-mono); }

Geist is the default new-Next font; you can swap in any Google Font by name. For local fonts, use next/font/local and point to a file in your project.

Subsets matter
Always pass a subsetsarray. Without it, Next can't compute the fallback metrics, and Latin-only sites end up shipping Cyrillic and Greek glyphs too. Specifying subsets makes the file smaller and the rendering correct.

Metadata API: SEO without head spaghetti

Every page.tsx and layout.tsx can export a metadata object (static) or a generateMetadata function (dynamic). Next merges them from root to leaf and renders the right <head> tags.

app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: {
    template: "%s | Acme",
    default: "Acme",
  },
  description: "We make widgets.",
  openGraph: { siteName: "Acme" },
};
app/posts/[slug]/page.tsx
import type { Metadata } from "next";

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return { title: "Not found" };

  return {
    title: post.title,
    description: post.summary,
    openGraph: {
      title: post.title,
      description: post.summary,
      images: [{ url: post.coverUrl, width: 1200, height: 630 }],
    },
    twitter: { card: "summary_large_image" },
  };
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <article>{post.content}</article>;
}

Templates from the root layout (%s | Acme) wrap whatever title the leaf returns, so title: "Hello" becomes Hello | Acme automatically.

OG images: render React to PNG at the edge

For Open Graph and Twitter card previews, you usually want a per-page image with the post title overlaid. Next does this with a file convention plus a JSX-to-image API.

app/posts/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";

export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

type Props = { params: Promise<{ slug: string }> };

export default async function Image({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          padding: 80,
          background: "linear-gradient(135deg, #0a0a0a, #1f1f1f)",
          color: "white",
          fontSize: 72,
          fontFamily: "sans-serif",
        }}
      >
        <div style={{ fontSize: 32, opacity: 0.7 }}>Acme Blog</div>
        <div style={{ marginTop: 24 }}>{post.title}</div>
      </div>
    ),
    { ...size },
  );
}

The file lives next to the route, returns an ImageResponse with arbitrary JSX, and Next renders it to PNG on demand. Drop a twitter-image.tsx next to it for the Twitter variant. The metadata API automatically links to both.

ImageResponse uses a subset of CSS
Because it runs on a tiny edge-friendly layout engine, only flex layout and a curated set of CSS properties work. No grid, no floats, no media queries. Stick to flexbox plus inline styles and it'll just work.

Other file conventions you get for free

  • app/icon.png or app/icon.tsx → favicons and apple-touch-icons.
  • app/robots.txt or app/robots.ts → robots file (static or dynamic).
  • app/sitemap.ts → typed sitemap. Export a function returning { url, lastModified } entries.
  • app/manifest.ts → PWA manifest.

Quiz

Quiz1 / 3

Why does next/image require width and height (or a static import) for remote URLs?

Recap

  • next/image gives you responsive, modern-format, lazy-loaded images with no layout shift. Use static imports for build-time dimensions and blur placeholders.
  • next/font self-hosts fonts, inlines fallback metrics, and exposes CSS variables. Geist is the default.
  • Export a metadata object (static) or generateMetadata function (dynamic) from any layout or page. Root templates wrap leaf titles.
  • app/.../opengraph-image.tsx with ImageResponse renders per-route OG PNGs from JSX.
  • File conventions also cover icons, robots, sitemap, and manifest. All typed, all colocated.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.