Image Optimization
The opengraph-image.tsx convention, proper image dimensions for social sharing, next/image for Core Web Vitals, and how image size affects page speed. You will hit this when your OG image gets cropped on LinkedIn or your LCP score tanks.
// app/opengraph-image.png
// A static PNG file in the app directory
// Same image for every page on the site
// app/blog/[slug]/page.tsx
export const metadata: Metadata = {
openGraph: {
images: ["/opengraph-image.png"],
},
};// app/opengraph-image.png
// A static PNG file in the app directory
// Same image for every page on the site
// app/blog/[slug]/page.tsx
export const metadata: Metadata = {
openGraph: {
images: ["/opengraph-image.png"],
},
};// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
return new ImageResponse(
(
<div style={{
display: "flex",
fontSize: 48,
background: "linear-gradient(135deg, \
#667eea 0%, #764ba2 100%)",
color: "white",
width: "100%",
height: "100%",
padding: 60,
alignItems: "center",
justifyContent: "center",
}}>
{post.title}
</div>
),
size
);
}// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
return new ImageResponse(
(
<div style={{
display: "flex",
fontSize: 48,
background: "linear-gradient(135deg, \
#667eea 0%, #764ba2 100%)",
color: "white",
width: "100%",
height: "100%",
padding: 60,
alignItems: "center",
justifyContent: "center",
}}>
{post.title}
</div>
),
size
);
}Using a single static image for every page means all shared links look identical in social feeds. Users scrolling through LinkedIn or Slack cannot tell your blog posts apart. Dynamic OG images are one of the highest-impact SEO improvements for content-heavy sites.
The opengraph-image.tsx file convention generates unique OG images per route segment. Next.js automatically wires up the og:image meta tag. Each blog post gets a branded, dynamic image with its title, which stands out in social feeds compared to a generic static image.
// app/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const size = { width: 600, height: 600 };
export const contentType = "image/png";
export default function Image() {
return new ImageResponse(
(
<div style={{
display: "flex",
width: "100%",
height: "100%",
background: "#000",
color: "#fff",
alignItems: "center",
justifyContent: "center",
fontSize: 32,
}}>
Acme Corp
</div>
),
size
);
}// app/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const size = { width: 600, height: 600 };
export const contentType = "image/png";
export default function Image() {
return new ImageResponse(
(
<div style={{
display: "flex",
width: "100%",
height: "100%",
background: "#000",
color: "#fff",
alignItems: "center",
justifyContent: "center",
fontSize: 32,
}}>
Acme Corp
</div>
),
size
);
}// app/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {
return new ImageResponse(
(
<div style={{
display: "flex",
width: "100%",
height: "100%",
background: "#000",
color: "#fff",
alignItems: "center",
justifyContent: "center",
fontSize: 48,
}}>
Acme Corp
</div>
),
size
);
}// app/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {
return new ImageResponse(
(
<div style={{
display: "flex",
width: "100%",
height: "100%",
background: "#000",
color: "#fff",
alignItems: "center",
justifyContent: "center",
fontSize: 48,
}}>
Acme Corp
</div>
),
size
);
}A 600x600 square image will be cropped to a landscape format on most platforms, cutting off the top and bottom. The smaller resolution also looks blurry on high-DPI screens. Always use 1200x630 for maximum compatibility across social platforms.
The standard OG image size is 1200x630 pixels (1.91:1 ratio). This fits perfectly on Facebook, LinkedIn, Twitter, Slack, and Discord without cropping. Using the correct dimensions ensures your text and branding are fully visible on every platform.
// components/testimonials.tsx
import Image from "next/image";
export function Testimonials({
items,
}: {
items: Testimonial[];
}) {
return (
<section>
{items.map((t) => (
<div key={t.id}>
<Image
src={t.avatarUrl}
alt={t.name}
width={64}
height={64}
priority
/>
<p>{t.quote}</p>
</div>
))}
</section>
);
// All avatars are preloaded even though
// they are far below the fold
}// components/testimonials.tsx
import Image from "next/image";
export function Testimonials({
items,
}: {
items: Testimonial[];
}) {
return (
<section>
{items.map((t) => (
<div key={t.id}>
<Image
src={t.avatarUrl}
alt={t.name}
width={64}
height={64}
priority
/>
<p>{t.quote}</p>
</div>
))}
</section>
);
// All avatars are preloaded even though
// they are far below the fold
}// components/testimonials.tsx
import Image from "next/image";
export function Testimonials({
items,
}: {
items: Testimonial[];
}) {
return (
<section>
{items.map((t) => (
<div key={t.id}>
<Image
src={t.avatarUrl}
alt={t.name}
width={64}
height={64}
loading="lazy"
/>
<p>{t.quote}</p>
</div>
))}
</section>
);
// Avatars load only when the section
// scrolls into view
}// components/testimonials.tsx
import Image from "next/image";
export function Testimonials({
items,
}: {
items: Testimonial[];
}) {
return (
<section>
{items.map((t) => (
<div key={t.id}>
<Image
src={t.avatarUrl}
alt={t.name}
width={64}
height={64}
loading="lazy"
/>
<p>{t.quote}</p>
</div>
))}
</section>
);
// Avatars load only when the section
// scrolls into view
}Adding priority to every image defeats the purpose of lazy loading. The browser preloads all images immediately, even those the user may never scroll to. This wastes bandwidth, slows down the initial page load, and can actually hurt your LCP score by competing with the real LCP element.
Images below the fold should use lazy loading (which is the default for next/image). This defers loading until the image is about to enter the viewport. Only the LCP image should use priority. Lazy loading reduces initial page weight and speeds up the first meaningful paint.
// components/hero.tsx
import Image from "next/image";
export function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero banner"
width={1920}
height={1080}
/>
);
}// components/hero.tsx
import Image from "next/image";
export function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero banner"
width={1920}
height={1080}
/>
);
}// components/hero.tsx
import Image from "next/image";
export function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero banner"
fill
sizes="100vw"
style={{ objectFit: "cover" }}
priority
/>
);
}// components/hero.tsx
import Image from "next/image";
export function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero banner"
fill
sizes="100vw"
style={{ objectFit: "cover" }}
priority
/>
);
}Setting fixed width and height on a hero image that should be responsive forces a single image size for all viewports. Mobile users download a 1920px image when they only need 375px. This wastes bandwidth and hurts Core Web Vitals, especially on slower connections.
Using fill with sizes tells the browser how wide the image will be at each breakpoint, so it can download the smallest appropriate version. The sizes='100vw' indicates a full-width image. Without sizes, the browser may download a larger image than needed, wasting bandwidth.
// components/hero.tsx
import Image from "next/image";
export function Hero() {
return (
<div>
<Image
src="/hero.jpg"
alt="Hero banner"
fill
sizes="100vw"
/>
</div>
);
// Image lazy loads by default,
// delaying LCP
}// components/hero.tsx
import Image from "next/image";
export function Hero() {
return (
<div>
<Image
src="/hero.jpg"
alt="Hero banner"
fill
sizes="100vw"
/>
</div>
);
// Image lazy loads by default,
// delaying LCP
}// components/hero.tsx
import Image from "next/image";
export function Hero() {
return (
<div>
<Image
src="/hero.jpg"
alt="Hero banner"
fill
sizes="100vw"
priority
/>
</div>
);
// Image eagerly loads and is preloaded,
// improving LCP
}// components/hero.tsx
import Image from "next/image";
export function Hero() {
return (
<div>
<Image
src="/hero.jpg"
alt="Hero banner"
fill
sizes="100vw"
priority
/>
</div>
);
// Image eagerly loads and is preloaded,
// improving LCP
}Without priority, Next.js lazy-loads the image by default. For below-the-fold images this is good, but for the hero image (which is usually the LCP element), lazy loading delays rendering until the browser scrolls or reaches the image during layout. This directly hurts your Core Web Vitals score.
The priority prop disables lazy loading and adds a preload link tag for the image. This is critical for the Largest Contentful Paint (LCP) element, which is often a hero image. Preloading the LCP image can improve your LCP score by hundreds of milliseconds.
// components/product-card.tsx
import Image from "next/image";
export function ProductCard({
product,
}: {
product: Product;
}) {
return (
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
unoptimized
/>
);
}// components/product-card.tsx
import Image from "next/image";
export function ProductCard({
product,
}: {
product: Product;
}) {
return (
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
unoptimized
/>
);
}// components/product-card.tsx
import Image from "next/image";
export function ProductCard({
product,
}: {
product: Product;
}) {
return (
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
quality={80}
/>
);
// Next.js serves WebP/AVIF automatically
// based on browser Accept header
}// components/product-card.tsx
import Image from "next/image";
export function ProductCard({
product,
}: {
product: Product;
}) {
return (
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
quality={80}
/>
);
// Next.js serves WebP/AVIF automatically
// based on browser Accept header
}The unoptimized prop bypasses Next.js image optimization entirely, serving the original file as-is. Users receive uncompressed PNGs or JPEGs that are often 2-5x larger than necessary. This increases page load time and bandwidth costs, and directly harms your Core Web Vitals scores.
Next.js automatically serves images in WebP or AVIF format when the browser supports them. These modern formats are 25-50% smaller than JPEG/PNG with similar quality. The quality prop controls compression level. Removing unoptimized lets the built-in image optimizer do its job.
// components/gallery.tsx
import Image from "next/image";
export function Gallery({
images,
}: {
images: GalleryImage[];
}) {
return (
<div className="grid grid-cols-3 gap-4">
{images.map((img) => (
<Image
key={img.id}
src={img.url}
alt={img.alt}
width={400}
height={300}
/>
))}
</div>
);
// Images pop in abruptly as they load
}// components/gallery.tsx
import Image from "next/image";
export function Gallery({
images,
}: {
images: GalleryImage[];
}) {
return (
<div className="grid grid-cols-3 gap-4">
{images.map((img) => (
<Image
key={img.id}
src={img.url}
alt={img.alt}
width={400}
height={300}
/>
))}
</div>
);
// Images pop in abruptly as they load
}// components/gallery.tsx
import Image from "next/image";
export function Gallery({
images,
}: {
images: GalleryImage[];
}) {
return (
<div className="grid grid-cols-3 gap-4">
{images.map((img) => (
<Image
key={img.id}
src={img.url}
alt={img.alt}
width={400}
height={300}
placeholder="blur"
blurDataURL={img.blurHash}
/>
))}
</div>
);
// Smooth transition from blur to sharp
}// components/gallery.tsx
import Image from "next/image";
export function Gallery({
images,
}: {
images: GalleryImage[];
}) {
return (
<div className="grid grid-cols-3 gap-4">
{images.map((img) => (
<Image
key={img.id}
src={img.url}
alt={img.alt}
width={400}
height={300}
placeholder="blur"
blurDataURL={img.blurHash}
/>
))}
</div>
);
// Smooth transition from blur to sharp
}Without a placeholder, images appear as empty rectangles that suddenly pop into view. This causes Cumulative Layout Shift (CLS) if dimensions are not properly set, and feels jarring to users. The abrupt appearance is especially noticeable on image galleries with many items.
The placeholder='blur' prop shows a blurred preview while the full image loads. This prevents layout shift (improving CLS scores) and provides a smoother visual experience. For remote images, you provide a base64-encoded blurDataURL generated at build time or from your CMS.
// components/hero.tsx
import Image from "next/image";
export function Hero() {
return (
<Image
src="/hero-desktop.jpg"
alt="Product showcase"
width={1920}
height={600}
sizes="100vw"
priority
/>
);
// Same wide landscape crop on mobile
// Important content gets tiny
}// components/hero.tsx
import Image from "next/image";
export function Hero() {
return (
<Image
src="/hero-desktop.jpg"
alt="Product showcase"
width={1920}
height={600}
sizes="100vw"
priority
/>
);
// Same wide landscape crop on mobile
// Important content gets tiny
}// components/hero.tsx
export function Hero() {
return (
<picture>
<source
media="(min-width: 768px)"
srcSet="/hero-desktop.jpg"
width={1920}
height={600}
/>
<img
src="/hero-mobile.jpg"
alt="Product showcase"
width={750}
height={750}
style={{ width: "100%", height: "auto" }}
fetchPriority="high"
/>
</picture>
);
// Mobile gets a square crop focused
// on the product
}// components/hero.tsx
export function Hero() {
return (
<picture>
<source
media="(min-width: 768px)"
srcSet="/hero-desktop.jpg"
width={1920}
height={600}
/>
<img
src="/hero-mobile.jpg"
alt="Product showcase"
width={750}
height={750}
style={{ width: "100%", height: "auto" }}
fetchPriority="high"
/>
</picture>
);
// Mobile gets a square crop focused
// on the product
}Serving a wide 1920x600 landscape image on mobile squeezes the content into a tiny strip. The main subject becomes too small to see clearly. Art direction solves this by providing a differently composed image for mobile, not just a smaller version of the same crop.
Art direction uses the <picture> element to serve different image crops for different screen sizes. A wide landscape hero on desktop can be replaced with a tighter square crop on mobile that keeps the important subject visible. This is different from responsive sizing, which just changes resolution.