Performance and Image Optimization
next/image, font optimization with next/font, and code splitting via next/dynamic.
next/image: automatic image optimization
An unoptimized <img> tag ships whatever file size and dimensions exist on disk, regardless of how large the image actually needs to render — a common, easy-to-miss source of slow page loads. Next.js's built-in <Image> component resizes, compresses, and serves images in a modern format (like WebP) automatically, and — just as importantly — reserves the correct space for the image before it loads, preventing the surrounding layout from jumping around as images arrive:
import Image from 'next/image';
import heroPhoto from '@/public/hero.jpg';
export default function HomePage() {
return (
<div>
<Image
src={heroPhoto}
alt="A team collaborating around a laptop"
width={800}
height={400}
priority // load this one eagerly — it's above the fold
/>
</div>
);
}
Importing a local image directly (import heroPhoto from '@/public/hero.jpg') lets Next.js read its real dimensions at build time, so width/height can even be inferred automatically in many cases — those two props exist specifically so the browser can reserve the correct aspect ratio before the image data has actually arrived, which is what prevents the page's layout from shifting once it loads (a real, measured metric — Cumulative Layout Shift — that search engines and users both notice). A remote image (fetched from a URL, not bundled locally) needs width/height supplied explicitly, plus its domain allow-listed in next.config.js:
// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.example.com' },
],
},
};
<Image
src="https://images.example.com/product-42.jpg"
alt="Product photo"
width={400}
height={400}
/>
That domain allow-list is a deliberate security boundary, not an arbitrary restriction — Next's image optimizer fetches and processes the source image on your server, so allowing arbitrary remote hosts would let anyone point your server at fetching and re-serving whatever image they wanted.
priority (used above on the hero image) tells Next.js to load that specific image eagerly, without the default lazy-loading behavior — reserve it for whatever image sits above the fold on first paint (the header/hero image), since marking too many images as high priority defeats the point of prioritizing any of them.
Font optimization with next/font
Loading a web font the traditional way — a <link> to Google Fonts, or a separate CSS @font-face — triggers an extra network request to a third-party server and can cause a visible flash of unstyled or substituted text while the font downloads. next/font downloads the font file at build time and self-hosts it alongside your own static assets, removing that external request and the layout shift that comes with swapping in a font late:
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // show a fallback font immediately, swap once the real font loads
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
The font is fetched once during the build, self-hosted from your own domain, and its className applies the right font-family plus generated @font-face rules — no request to fonts.googleapis.com at runtime, and no separate <link> tag to manage. A locally-provided font file works the same way through next/font/local instead of next/font/google.
Code splitting with dynamic imports
By default, Next.js already splits code per route — visiting /about doesn't download the JavaScript for /dashboard. next/dynamic goes a level finer, letting you split out one specific, heavy component within a page, so its code only downloads when that component actually needs to render:
'use client';
import dynamic from 'next/dynamic';
import { useState } from 'react';
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <p>Loading chart...</p>,
ssr: false, // skip server-rendering this component entirely (e.g. it depends on a browser-only charting library)
});
export default function AnalyticsPage() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show chart</button>
{showChart && <HeavyChart />}
</div>
);
}
HeavyChart's JavaScript bundle isn't downloaded at all until showChart becomes true — a real win for a component that's genuinely large (a charting library, a rich text editor, a map widget) and isn't needed by every visitor on every load. ssr: false is specifically for a component that can't render on the server at all (one that reaches for window or a browser-only API the moment it renders) — omit it for anything that renders fine on the server and only needs the code-splitting benefit, not an SSR opt-out.
Common mistakes
- Using a plain
<img>tag for a normal content image instead ofnext/image— it works, but loses automatic resizing, format conversion, and layout-shift prevention for no benefit. - Marking every image
priority"to be safe" — that defeats the purpose of prioritizing the genuinely above-the-fold image, since the browser ends up trying to eagerly load everything at once again. - Forgetting to allow-list a remote image domain in
next.config.js'simages.remotePatterns— the build (or the request) fails until the domain serving that image is explicitly permitted. - Reaching for
next/dynamicon a small, cheap component "just in case" — the overhead of an extra chunk and a loading state isn't worth it unless the component is genuinely large or rarely needed on first render.