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, andtwitter-image.pngin route segments are automatically picked up. - Dynamic image generation uses
ImageResponsefromnext/og, which renders JSX to an image using Satori (a library that converts HTML/CSS to SVG) and then converts to PNG. opengraph-image.tsxfiles can be placed at any route segment level. A file atapp/blog/[slug]/opengraph-image.tsxgenerates 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 useruntime = "nodejs"if you need Node.js APIs. generateMetadataand 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
ImageResponseis imported fromnext/og. It accepts JSX as its first argument and an options object as the second.- The
Metadatatype fromnextprovides full typing for all metadata fields includingopenGraph,twitter, andicons. - 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
ImageResponseJSX is not full React. It supports a subset of CSS (flexbox layout only, no grid, noposition: absolutealternatives are limited). Check the Satori documentation for supported CSS properties.- Font files must be loaded as
ArrayBuffer. You cannot use CSS@font-faceor Google Fonts URLs directly inImageResponse. - OG images must be exactly 1200x630 pixels for optimal display across social platforms. Twitter cards also use 1200x630 for
summary_large_image. favicon.icomust be in theapp/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-imagewithurl()is not supported. Use theimgtag in the JSX instead. - The
altexport inopengraph-image.tsxis required for accessibility. It becomes theog:image:altmeta tag. - Apple touch icons should be 180x180 pixels. Other sizes are generated automatically by iOS.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| File-based (opengraph-image.tsx) | Auto-discovery, per-route images, type-safe | Satori CSS limitations |
| API route (/api/og) | Full control, reusable across routes | Manual metadata wiring |
| Static images | Simplest, no compute cost | Same image for every page |
| Cloudinary OG | Advanced transformations, text overlays | External service, cost |
| @vercel/og (standalone) | Works outside Next.js | Requires 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_imagecards also use 1200x630.
Gotcha: What CSS limitations exist inside ImageResponse JSX?
- Only flexbox layout is supported -- no CSS Grid.
background-imagewithurl()is not supported; use animgtag instead.position: absolutealternatives 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";Metadataprovides full typing foropenGraph,twitter, andiconsfields.generateMetadatamust returnPromise<Metadata>.ResolvingMetadatalets you access parent metadata for merging images.
Gotcha: Why is the alt export required in opengraph-image.tsx?
- It becomes the
og:image:altmeta 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. generateMetadatahandles 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" },
],
};
}