Why next/image exists, the four Script loading strategies, the new Form component for prefetched search, and what Link's prefetch prop actually controls.
F-6 — Built-in Components: Image, Font, Script, Link, and Form
Who this is for: Developers who have the routing and data fetching fundamentals from F-2 through F-5. This module covers the performance-focused built-in components that Next.js provides to solve specific web performance problems that raw HTML handles poorly. Each one exists because the naive implementation has a measurable negative impact on Core Web Vitals.
Why Built-in Components Exist
Every component in this module replaces something you've almost certainly done the naive way — and the naive way works, in the sense that it renders. But each naive approach has a specific performance pathology that shows up in real metrics.
Unoptimised images: Largest Contentful Paint catastrophe. Raw @import fonts: FOUT (Flash of Unstyled Text) and layout shift. Synchronous third-party <script> tags: parser blocking and slower Time to Interactive. Regular <a> tags: no prefetching, no client-side navigation.
Next.js's built-in components solve these problems systematically so you don't have to rediscover them project by project. Understanding why each one works the way it does means you'll use them correctly instead of fighting them.
next/image — Automatic Image Optimization
The <Image> component from next/image does four things that a raw <img> tag doesn't:
- Serves modern formats automatically — converts JPEGs and PNGs to WebP (or AVIF where supported), reducing file size by 25–50%
- Resizes on demand — serves exactly the size the layout needs, not a 4K image shrunk with CSS
- Lazy loads below-the-fold images — only downloads images when they enter the viewport
- Prevents layout shift — reserves the correct space before the image loads, eliminating Cumulative Layout Shift
tsximport Image from 'next/image'; // Local image (TypeScript knows the dimensions at build time) import heroPhoto from '@/public/hero.jpg'; export default function Hero() { return ( <Image src={heroPhoto} alt="Hero image" priority // ← loads eagerly — use for above-the-fold images /> ); }
For remote images, you must specify width and height explicitly and configure allowed remote origins in next.config.ts:
tsx// ❌ This will fail without dimensions <Image src="https://images.example.com/photo.jpg" alt="Photo" /> // ✅ Width and height required for remote images <Image src="https://images.example.com/photo.jpg" alt="Photo" width={800} height={600} className="rounded-lg" />
ts// next.config.ts — allowlist remote image origins import type { NextConfig } from 'next'; const config: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'images.example.com', }, ], }, }; export default config;
The security model: allowing arbitrary remote URLs would let anyone use your Next.js server as a free image resize proxy. The allowlist prevents that.
fill for Responsive Containers
When you don't know the image dimensions (dynamic content, CMS images), use fill to let the image fill its parent container:
tsx<div className="relative h-64 w-full"> <Image src={product.imageUrl} alt={product.name} fill className="object-cover" // ← CSS determines how image fits the container sizes="(max-width: 768px) 100vw, 50vw" // ← tells Next.js which sizes to generate /> </div>
The parent must have position: relative (or absolute/fixed). The sizes attribute is important — it tells the browser (and Next.js's image optimizer) which image size to serve at each breakpoint. Without it, Next.js defaults to 100vw, potentially serving much larger images than needed.
priority vs Lazy Loading
By default, images are lazy-loaded. The one exception: images that are visible immediately on page load — your hero image, your above-the-fold product photo — should have priority. This tells Next.js to preload them with <link rel="preload"> in the document <head>, improving LCP.
tsx// Above the fold — use priority <Image src={heroImage} alt="Hero" priority /> // Below the fold — lazy loading is correct (default) <Image src={productImage} alt={product.name} />
LCP (Largest Contentful Paint) is Google's primary Core Web Vitals metric. The LCP element is almost always a hero image or above-the-fold photo. Forgetting priority on the LCP image is one of the most common Next.js performance mistakes — your Lighthouse score will tell you if you've missed it.
next/font — Zero Layout Shift Typography
Fonts are a well-documented performance problem. The traditional approach — @import url('https://fonts.google.com/...') — blocks rendering, can cause Flash of Unstyled Text, and triggers Cumulative Layout Shift when the font loads and text reflows.
next/font solves this by downloading the font at build time, self-hosting it, and generating CSS with font-display: swap and fallback metrics calculated to match the Google font's actual glyph shapes.
Google Fonts
tsx// app/layout.tsx import { Inter, Sora } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap', // ← show fallback while font loads, then swap variable: '--font-inter', // ← CSS custom property for use in Tailwind }); const sora = Sora({ subsets: ['latin'], weight: ['400', '600', '700'], variable: '--font-sora', }); export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" className={`${inter.variable} ${sora.variable}`}> <body className={inter.className}> {children} </body> </html> ); }
Using the variable option exposes the font as a CSS custom property. In Tailwind config:
ts// tailwind.config.ts export default { theme: { extend: { fontFamily: { sans: ['var(--font-inter)'], display: ['var(--font-sora)'], }, }, }, };
Then font-sans uses Inter and font-display uses Sora anywhere in your Tailwind classes.
Local Fonts
For custom, licensed, or variable fonts:
tsximport localFont from 'next/font/local'; const customFont = localFont({ src: [ { path: '../../public/fonts/CustomFont-Regular.woff2', weight: '400' }, { path: '../../public/fonts/CustomFont-Bold.woff2', weight: '700' }, ], variable: '--font-custom', display: 'swap', });
Why This Matters
Without next/font, you either:
- Suffer CLS when fonts load and text reflows
- Block rendering to avoid CLS (a different but equally bad trade-off)
- Carefully calculate
size-adjust,ascent-override, anddescent-overrideCSS properties by hand to match your fallback font's metrics
next/font does option 3 automatically, at build time, with zero manual CSS. The fallback font is sized and positioned to match the final font so precisely that the swap is visually imperceptible. In practice, your layout doesn't shift at all.
next/script — Controlled Third-Party Loading
Raw <script> tags in HTML block the parser. Every millisecond your analytics, chat widget, or A/B testing SDK spends loading is a millisecond the browser isn't rendering your content.
next/script gives you explicit control over when third-party scripts load:
tsximport Script from 'next/script'; export default function RootLayout({ children }) { return ( <html lang="en"> <body> {children} <Script src="https://analytics.example.com/script.js" strategy="afterInteractive" /> </body> </html> ); }
The Four Strategies
beforeInteractive — loads and executes before the page is interactive. Use this only for scripts that must be available before the user can interact (consent managers, critical A/B testing that affects above-the-fold content). This strategy blocks hydration.
afterInteractive (default) — loads after the page becomes interactive. Correct for most analytics, chat widgets, and non-critical third parties. The page is usable immediately; the script loads in the background.
lazyOnload — loads during browser idle time, lowest priority. Use for scripts that have no urgency whatsoever — social sharing widgets, survey tools, anything that can wait until the page is fully loaded and the user is reading.
worker (experimental) — moves script execution to a Web Worker, keeping the main thread free. Use for heavy scripts that do significant computation. Currently requires enabling nextScriptWorkers: true in next.config.ts.
Inline Scripts
For inline script content (initialisation code, window variables):
tsx<Script id="datadog-init" strategy="beforeInteractive"> {` window.DD_LOGS = window.DD_LOGS || []; window.DD_LOGS.push({ clientToken: '${process.env.DD_CLIENT_TOKEN}' }); `} </Script>
The id prop is required for inline scripts — Next.js uses it to deduplicate scripts across renders.
next/link Revisited — What's Actually Happening
You've used <Link> since F-5. This module adds the details that matter for performance.
When a <Link> enters the viewport, Next.js starts fetching the destination route in the background. For static routes, this is a fetch of the pre-rendered HTML. For dynamic routes, it fetches the RSC payload (the serialized Server Component output). By the time the user clicks, the content is cached — navigation appears instant.
The prefetch cache lives in the React Cache and is separate from the HTTP cache. It's ephemeral — it survives for the duration of the session but not across page reloads.
tsximport Link from 'next/link'; // Renders as <a href="/blog"> — standard HTML anchor for accessibility and crawlers <Link href="/blog">Blog</Link> // Programmatic state alongside navigation <Link href="/checkout" className="btn-primary"> Checkout </Link> // External links — use regular <a>, not Link <a href="https://github.com/yourusername" target="_blank" rel="noopener noreferrer"> GitHub </a>
Use <Link> for internal navigation. Use <a> for external links. The difference matters: <Link> triggers client-side navigation (fast, no full page reload). <a> does a full browser navigation (reloads the page, loses React state).
replace vs push
tsx// Adds a new entry to the browser history stack (default) <Link href="/dashboard">Dashboard</Link> // Replaces the current history entry (no back button entry created) <Link href="/dashboard" replace>Dashboard</Link>
Use replace when navigating after form submission or login — you don't want the user to press back and re-see the form or re-trigger auth flow.
<Form> — The Prefetch-Aware Search Component (Next.js 15)
Next.js 15 introduced a <Form> component from next/form that extends the HTML <form> with two capabilities: soft navigation for GET forms and route prefetching on focus.
tsximport Form from 'next/form'; export default function SearchBar() { return ( <Form action="/search"> <input name="q" type="search" placeholder="Search products..." className="border rounded px-3 py-2" /> <button type="submit">Search</button> </Form> ); }
When the user submits this form, instead of a full page reload to /search?q=value, Next.js does a client-side navigation — the same as router.push('/search?q=value'). The URL updates, the search results page renders via RSC, and React state outside the search results is preserved.
On focus of the search input, <Form> also prefetches the /search route in the background. By the time the user finishes typing and submits, the route's server component shell is already cached.
For POST forms (mutations), use Server Actions instead of <Form> — that's covered in P-2.
tsx// Only use next/form for GET forms (search, filters, navigation) // For POST forms (create/update/delete), use Server Actions with regular <form>
Combining Them: A Product Listing Page
Here's how these components compose in practice — a product listing page that gets most things right by default:
tsx// app/products/page.tsx (Server Component) import Image from 'next/image'; import Link from 'next/link'; import Form from 'next/form'; import { Suspense } from 'react'; import FilterBar from '@/components/FilterBar'; interface PageProps { searchParams: Promise<{ q?: string; category?: string }>; } export default async function ProductsPage({ searchParams }: PageProps) { const { q, category } = await searchParams; const products = await getProducts({ search: q, category }); return ( <div className="max-w-6xl mx-auto px-4 py-8"> <Form action="/products" className="mb-6"> <input name="q" defaultValue={q} placeholder="Search products" /> {category && <input type="hidden" name="category" value={category} />} <button type="submit">Search</button> </Form> <Suspense fallback={<FilterBarSkeleton />}> <FilterBar activeCategory={category} /> </Suspense> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6"> {products.map((product, index) => ( <Link key={product.id} href={`/products/${product.id}`} className="group block" > <div className="relative aspect-square overflow-hidden rounded-lg"> <Image src={product.imageUrl} alt={product.name} fill className="object-cover group-hover:scale-105 transition-transform" sizes="(max-width: 768px) 50vw, 25vw" priority={index < 4} // ← above-the-fold products load eagerly /> </div> <p className="mt-2 font-medium">{product.name}</p> <p className="text-zinc-400">${product.price}</p> </Link> ))} </div> </div> ); }
What this page does well, automatically:
- Images are served in WebP, sized to the exact grid cell, lazy-loaded (except the first row)
- Links prefetch their destinations when they enter the viewport
- Search form does client-side navigation without a full page reload
- Font is self-hosted, layout-shift-free (set up in
layout.tsx)
None of this required hand-rolling performance optimizations. It's the default behavior of these components used correctly.
Common Mistakes
Forgetting sizes on fill images. Without sizes, Next.js generates an image at 100vw width even when the image occupies 25% of the screen. For a 4-column grid, the correct sizes is 25vw (or more accurately, (max-width: 768px) 50vw, 25vw to handle the 2-column mobile layout).
Using <img> instead of <Image> for remote images you control. If you're serving images from S3, Cloudinary, or your own CDN and want optimization, use next/image. If you're linking to external images you can't control (user avatars, third-party logos), you can use <img> with unoptimized if optimization isn't needed.
Putting <Script strategy="beforeInteractive"> on everything. Every beforeInteractive script delays hydration. Use it only for scripts that must exist before the first interaction — typically just consent managers. Everything else is afterInteractive or lazyOnload.
Loading Google Fonts with @import in CSS. Once you know next/font exists, there's no reason to use @import. The performance difference is measurable in LCP and CLS scores.
Where We Go From Here
F-7 covers Route Handlers — building API endpoints inside Next.js with the route.ts file convention, the new proxy.js convention for declarative API proxying, and when a Route Handler is the right tool versus a Server Action.
After F-7 comes F-8 — the capstone module where you build a complete content site end-to-end, putting everything from F-1 through F-7 into practice in a real application before moving into the Practitioner phase.