React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

faviconog-imagemetadataopengraphtwitter-cardImageResponse

Favicons & OG Images

Recipe

Use Next.js App Router conventions to configure favicons, generate dynamic Open Graph images, and set up social sharing metadata. Next.js supports both file-based and config-based approaches.

Favicon setup (file-based):

Place icon files in the app/ directory with conventional names:

app/
  favicon.ico          # Browser tab icon (32x32 or 16x16)
  icon.png             # Modern browsers (32x32)
  icon.svg             # Scalable icon for modern browsers
  apple-icon.png       # Apple touch icon (180x180)

Next.js automatically generates the correct <link> tags in <head> for these files.

Static OG image (file-based):

app/
  opengraph-image.png  # Default OG image (1200x630)
  twitter-image.png    # Twitter card image (1200x630)

Metadata config approach:

// app/layout.tsx
import type { Metadata } from "next";
 
export const metadata: Metadata = {
  title: {
    default: "My App",
    template: "%s | My App",
  },
  description: "A description of my app",
  icons: {
    icon: [
      { url: "/favicon.ico", sizes: "32x32" },
      { url: "/icon.svg", type: "image/svg+xml" },
    ],
    apple: [{ url: "/apple-icon.png", sizes: "180x180" }],
  },
  openGraph: {
    title: "My App",
    description: "A description of my app",
    url: "https://myapp.com",
    siteName: "My App",
    images: [
      {
        url: "/og-image.png",
        width: 1200,
        height: 630,
        alt: "My App preview",
      },
    ],
    locale: "en_US",
    type: "website",
  },
  twitter: {
    card: "summary_large_image",
    title: "My App",
    description: "A description of my app",
    images: ["/twitter-image.png"],
  },
};

Working Example

A dynamic OG image generator for blog posts using ImageResponse:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
 
export const runtime = "edge";
 
export const alt = "Blog post preview";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
 
interface Props {
  params: Promise<{ slug: string }>;
}
 
async function getPost(slug: string) {
  // Replace with your data fetching logic
  const posts: Record<string, { title: string; author: string; category: string; date: string }> = {
    "getting-started": {
      title: "Getting Started with Next.js",
      author: "Jane Smith",
      category: "Tutorial",
      date: "2026-03-15",
    },
    "server-components": {
      title: "Understanding React Server Components",
      author: "John Doe",
      category: "Deep Dive",
      date: "2026-03-20",
    },
  };
  return posts[slug] || { title: "Blog Post", author: "Author", category: "General", date: "2026-01-01" };
}
 
export default async function OGImage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
 
  return new ImageResponse(
    (
      <div
        style={{
          height: "100%",
          width: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          padding: "60px",
          background: "linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%)",
          color: "white",
          fontFamily: "system-ui, sans-serif",
        }}
      >
        {/* Category badge */}
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: "12px",
          }}
        >
          <div
            style={{
              backgroundColor: "#3b82f6",
              borderRadius: "9999px",
              padding: "6px 16px",
              fontSize: "16px",
              fontWeight: 600,
            }}
          >
            {post.category}
          </div>
        </div>
 
        {/* Title */}
        <div
          style={{
            display: "flex",
            flexDirection: "column",
            gap: "16px",
          }}
        >
          <div
            style={{
              fontSize: "56px",
              fontWeight: 800,
              lineHeight: 1.1,
              letterSpacing: "-0.02em",
              maxWidth: "900px",
            }}
          >
            {post.title}
          </div>
        </div>
 
        {/* Footer */}
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            fontSize: "20px",
            color: "#94a3b8",
          }}
        >
          <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
            <div
              style={{
                width: "40px",
                height: "40px",
                borderRadius: "50%",
                backgroundColor: "#3b82f6",
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                color: "white",
                fontSize: "18px",
                fontWeight: 700,
              }}
            >
              {post.author[0]}
            </div>
            <span>{post.author}</span>
          </div>
          <span>{post.date}</span>
        </div>
      </div>
    ),
    {
      ...size,
    }
  );
}
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
 
interface PageProps {
  params: Promise<{ slug: string }>;
}
 
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params;
  // The OG image is auto-discovered from opengraph-image.tsx
  // but you can add additional metadata here
  return {
    title: `Blog: ${slug}`,
    description: `Read about ${slug}`,
  };
}
 
export default async function BlogPost({ params }: PageProps) {
  const { slug } = await params;
 
  return (
    <article className="prose mx-auto max-w-3xl px-4 py-8">
      <h1>{slug}</h1>
      <p>Blog post content here...</p>
    </article>
  );
}

Dynamic favicon with icon.tsx:

// app/icon.tsx
import { ImageResponse } from "next/og";
 
export const size = { width: 32, height: 32 };
export const contentType = "image/png";
 
export default function Icon() {
  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          background: "#0f172a",
          borderRadius: "6px",
          color: "white",
          fontSize: "20px",
          fontWeight: 800,
        }}
      >
        A
      </div>
    ),
    { ...size }
  );
}

Deep Dive

How It Works

  • Next.js App Router uses file conventions to generate metadata. Files named favicon.ico, icon.png, apple-icon.png, opengraph-image.png, and twitter-image.png in route segments are automatically picked up.
  • Dynamic image generation uses ImageResponse from next/og, which renders JSX to an image using Satori (a library that converts HTML/CSS to SVG) and then converts to PNG.
  • opengraph-image.tsx files can be placed at any route segment level. A file at app/blog/[slug]/opengraph-image.tsx generates unique OG images per blog post.
  • The runtime = "edge" export ensures the image is generated at the edge for fast response times. You can also use runtime = "nodejs" if you need Node.js APIs.
  • generateMetadata and file-based metadata can coexist. File-based images take precedence for their specific metadata fields.
  • Next.js adds proper cache headers to generated images, so they are cached by CDNs and browsers.

Variations

Custom fonts in OG images:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
 
export const runtime = "edge";
 
export default async function OGImage() {
  const fontData = await fetch(
    new URL("../../../public/fonts/Inter-Bold.ttf", import.meta.url)
  ).then((res) => res.arrayBuffer());
 
  return new ImageResponse(
    (
      <div style={{ fontFamily: "Inter", fontSize: 48, fontWeight: 700 }}>
        Custom Font Title
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: "Inter",
          data: fontData,
          style: "normal",
          weight: 700,
        },
      ],
    }
  );
}

Web app manifest icons:

// app/manifest.ts
import type { MetadataRoute } from "next";
 
export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "My App",
    short_name: "App",
    start_url: "/",
    display: "standalone",
    background_color: "#ffffff",
    theme_color: "#0f172a",
    icons: [
      { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
    ],
  };
}

TypeScript Notes

  • ImageResponse is imported from next/og. It accepts JSX as its first argument and an options object as the second.
  • The Metadata type from next provides full typing for all metadata fields including openGraph, twitter, and icons.
  • Dynamic metadata functions must return Promise<Metadata>.
import type { Metadata, ResolvingMetadata } from "next";
 
export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> },
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { slug } = await params;
  const previousImages = (await parent).openGraph?.images || [];
 
  return {
    openGraph: {
      images: [`/api/og?title=${slug}`, ...previousImages],
    },
  };
}

Gotchas

  • ImageResponse JSX is not full React. It supports a subset of CSS (flexbox layout only, no grid, no position: absolute alternatives are limited). Check the Satori documentation for supported CSS properties.
  • Font files must be loaded as ArrayBuffer. You cannot use CSS @font-face or Google Fonts URLs directly in ImageResponse.
  • OG images must be exactly 1200x630 pixels for optimal display across social platforms. Twitter cards also use 1200x630 for summary_large_image.
  • favicon.ico must be in the app/ root directory. Placing it in a subdirectory will not work.
  • Dynamic OG image routes add to your Edge Function invocations. If you have thousands of pages, consider caching strategies or static generation.
  • Satori does not support all CSS properties. Notably, background-image with url() is not supported. Use the img tag in the JSX instead.
  • The alt export in opengraph-image.tsx is required for accessibility. It becomes the og:image:alt meta tag.
  • Apple touch icons should be 180x180 pixels. Other sizes are generated automatically by iOS.

Alternatives

ApproachProsCons
File-based (opengraph-image.tsx)Auto-discovery, per-route images, type-safeSatori CSS limitations
API route (/api/og)Full control, reusable across routesManual metadata wiring
Static imagesSimplest, no compute costSame image for every page
Cloudinary OGAdvanced transformations, text overlaysExternal service, cost
@vercel/og (standalone)Works outside Next.jsRequires separate setup

FAQs

What file names does Next.js App Router automatically detect for favicons and OG images?
  • favicon.ico -- browser tab icon.
  • icon.png / icon.svg -- modern browser icons.
  • apple-icon.png -- Apple touch icon (180x180).
  • opengraph-image.png -- default OG image (1200x630).
  • twitter-image.png -- Twitter card image (1200x630).
How does ImageResponse from next/og generate images?
  • It renders JSX using Satori (which converts HTML/CSS to SVG).
  • The SVG is then converted to PNG.
  • It supports only a subset of CSS (flexbox only, no grid).
What is the recommended size for Open Graph images?
  • 1200x630 pixels for optimal display across social platforms.
  • Twitter summary_large_image cards also use 1200x630.
Gotcha: What CSS limitations exist inside ImageResponse JSX?
  • Only flexbox layout is supported -- no CSS Grid.
  • background-image with url() is not supported; use an img tag instead.
  • position: absolute alternatives are limited.
  • Check the Satori documentation for the full list of supported CSS properties.
How do you load custom fonts in a dynamic OG image?
const fontData = await fetch(
  new URL("../../../public/fonts/Inter-Bold.ttf", import.meta.url)
).then((res) => res.arrayBuffer());
 
return new ImageResponse(<div style={{ fontFamily: "Inter" }}>Title</div>, {
  fonts: [{ name: "Inter", data: fontData, weight: 700 }],
});

Fonts must be loaded as ArrayBuffer. CSS @font-face and Google Fonts URLs do not work.

Where must favicon.ico be placed in the project?
  • In the app/ root directory.
  • Placing it in a subdirectory will not work.
  • Next.js automatically generates the correct <link> tag for it.
How do you generate a dynamic favicon using icon.tsx?
// app/icon.tsx
import { ImageResponse } from "next/og";
 
export const size = { width: 32, height: 32 };
export const contentType = "image/png";
 
export default function Icon() {
  return new ImageResponse(
    <div style={{ background: "#0f172a", color: "white", fontSize: "20px" }}>A</div>,
    { ...size }
  );
}
What TypeScript types are used for metadata and OG image generation?
import type { Metadata, ResolvingMetadata } from "next";
  • Metadata provides full typing for openGraph, twitter, and icons fields.
  • generateMetadata must return Promise<Metadata>.
  • ResolvingMetadata lets you access parent metadata for merging images.
Gotcha: Why is the alt export required in opengraph-image.tsx?
  • It becomes the og:image:alt meta tag for accessibility.
  • Without it, screen readers and social platforms have no description of the image.
Can file-based metadata and generateMetadata coexist?
  • Yes, they can coexist in the same route segment.
  • File-based images (e.g., opengraph-image.tsx) take precedence for their specific metadata fields.
  • generateMetadata handles other fields like title and description.
What Apple touch icon size should you use?
  • 180x180 pixels.
  • Other sizes are generated automatically by iOS.
How do you set up a web app manifest with icons in Next.js?
// app/manifest.ts
import type { MetadataRoute } from "next";
 
export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "My App",
    icons: [
      { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
    ],
  };
}