UX & Assets — Cheat Sheet
Next.js · 6 topics. Download the PDF or the Instagram carousel and share it.
Metadata and SEO
Export an object or a function and Next renders the head tags. For a content site this is the feature that decides whether the pages exist as far as Google is concerned.
- ✓Export metadata for static pages and generateMetadata for dynamic ones — its data fetch is memoized with the page's
- ✓metadataBase is what makes canonical and Open Graph URLs absolute; without it they are effectively ignored
- ✓A title template keeps branding consistent, and the specific part belongs first because results truncate the end
- ✓Generate sitemap.ts and robots.ts from data rather than maintaining files by hand
- ✓JSON-LD drives rich results and ImageResponse generates a real OG image per page, with a limited CSS subset
// A static page
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Problems',
description: 'Practice DSA problems with Java and Python solutions.',
}
// The root layout sets the template and the defaults
export const metadata: Metadata = {
metadataBase: new URL('https://aicancode.org'), // makes relative URLs work
title: {
default: 'AiCanCode — Learn with Aria',
template: '%s | AiCanCode', // child pages fill %s
},
description: '…',
}
// -> "Problems | AiCanCode". Front-load the specific part: a search
// result truncates the end, not the beginning.
// A dynamic page
export async function generateMetadata({ params }): Promise<Metadata> {
const problem = await getProblem((await params).slug) // memoized —
if (!problem) return { title: 'Not found' } // the page's own
// await is free
return {
title: problem.title,
description: problem.summary.slice(0, 155),
alternates: { canonical: `/problems/${problem.slug}` },
openGraph: {
title: problem.title,
description: problem.summary,
images: [{ url: `/problems/${problem.slug}/opengraph-image` }],
type: 'article',
},
}
}Images and Media
next/image resizes, converts to modern formats, lazy-loads and reserves space in one component. The defaults are right; the mistakes are priority and remote patterns.
- ✓next/image reserves space, serves modern formats and lazy-loads below the fold by default
- ✓The hero image needs priority — lazy-loading the LCP element is the most common mistake with this component
- ✓fill requires a positioned, sized parent, and sizes tells the browser the rendered width
- ✓Remote hosts must be allow-listed in remotePatterns, which is a security control as well as configuration
- ✓Image optimisation is billed per transform on Vercel; there is no next/video, so use a real video host
import Image from 'next/image'
// A local import gives Next the dimensions automatically
import cover from '@/public/cover.png'
<Image src={cover} alt="" placeholder="blur" />
// A remote image needs explicit dimensions — they reserve the space
<Image src={problem.coverUrl} alt="" width={640} height={360} />
// Unknown dimensions: fill, with a positioned, sized parent
<div className="relative aspect-video">
<Image src={url} alt="" fill className="object-cover" />
</div>
// fill without a positioned parent silently produces a broken layout.
// The hero — the LCP element. Never lazy, always priority:
<Image src={hero} alt="" priority sizes="100vw" />
// priority preloads it and disables lazy loading. Missing it on the
// LCP image is the single most common next/image mistake, and Next
// warns about it in development.
// sizes tells the browser how wide the image will RENDER, so it can
// pick the right file. Without it, a full-width file is downloaded
// for a thumbnail:
<Image src={url} alt="" fill sizes="(max-width: 768px) 100vw, 33vw" />
// alt is required — an empty string for decorative images.Fonts and Styling
next/font self-hosts a webfont at build time so there is no third-party request and no layout shift. Styling is Tailwind or CSS Modules, with one server-side wrinkle for themes.
- ✓next/font downloads and self-hosts at build time, removing a third-party request and preloading automatically
- ✓Its size-adjusted fallback stops the font swap shifting the layout, which measurably reduces CLS
- ✓Tailwind and CSS Modules work in server components; CSS-in-JS forces client boundaries and fights the model
- ✓tailwind-merge is what makes a caller-supplied className actually override a component's own classes
- ✓The server cannot know the theme, so avoid the dark-mode flash with a blocking inline script or a theme cookie
import { Inter, JetBrains_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'], // required — keeps the file small
display: 'swap',
variable: '--font-sans', // exposes a CSS variable
})
const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' })
// app/layout.tsx
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
/* globals.css */
body { font-family: var(--font-sans), system-ui, sans-serif }
code { font-family: var(--font-mono), monospace }
// What this does that a <link> to Google Fonts does not:
// - downloads the file at BUILD time and serves it from your origin
// (no runtime request to fonts.gstatic.com, so no third-party
// round trip and nothing to consent to)
// - preloads it automatically
// - generates a size-adjusted fallback, so the swap does not shift
// the layout — measurable CLS, removed
// A local font
import localFont from 'next/font/local'
const brand = localFont({ src: './brand.woff2', variable: '--font-brand' })
// Load fonts in the ROOT layout only. Per-page font loading means
// a different subset per route and no shared cache.Errors, 404s and Redirects
Four files and two functions cover every failure path. The subtlety is that notFound() and redirect() work by throwing, which interacts badly with try/catch.
- ✓error.tsx is a client component that catches its segment and below; global-error.tsx covers a failing root layout
- ✓Production redacts error.message and gives a digest instead — show it so support can match a log line
- ✓notFound() and redirect() throw, so calling them inside a try/catch silently swallows them
- ✓A "not found" page that returns 200 is a soft 404 and gets indexed; notFound() sends a real 404
- ✓Handle static redirects in next.config, request-dependent ones in middleware, and data-dependent ones in the page
app/
error.tsx // catches this segment and below ('use client')
not-found.tsx // rendered by notFound(), and for unmatched URLs
global-error.tsx // catches errors in the ROOT layout itself
problems/
error.tsx // a failure here keeps the site header
// error.tsx — the reset function re-renders the segment
'use client'
export default function Error({ error, reset }: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => { logToSentry(error) }, [error])
return (
<div role="alert" className="p-8 text-center">
<h2 className="font-semibold">We could not load this.</h2>
<p className="text-sm text-muted-foreground">
{error.digest && `Reference: ${error.digest}`}
</p>
<button onClick={reset}>Try again</button>
<Link href="/">Go home</Link>
</div>
)
}
// In production, error.message is redacted to a generic string and a
// digest is provided instead — the digest matches a line in your
// server logs. Show it, so a support message is traceable.
// global-error.tsx must render its own <html> and <body>, because the
// root layout is what failed.Navigation Feel — Pending States and Transitions
App Router navigation waits for the server before anything changes on screen. Without a pending indicator the app feels frozen, and the fix is a hook rather than a spinner library.
- ✓A Link click fetches from the server, so nothing changes on screen until it lands — the app can feel frozen on a slow connection
- ✓useLinkStatus gives a per-link pending state; a useTransition around router.push does the same on older versions
- ✓A global progress bar needs Suspense around useSearchParams or it makes the tree dynamic
- ✓Prefetching happens in production only, which is why dev feels slower — measure perceived speed on a build
- ✓Disable prefetch on long lists of rarely-clicked links, and move focus to the heading on route change
// A click on <Link> triggers a server round trip for the new segment.
// Until it lands, React keeps showing the CURRENT page. No spinner,
// no visual acknowledgement — the app looks unresponsive.
// loading.tsx helps only once the navigation has committed, which is
// after the wait people notice.
// The direct fix (Next 15+): a pending state per link
'use client'
import { useLinkStatus } from 'next/link'
function LinkSpinner() {
const { pending } = useLinkStatus()
return pending ? <Spinner className="h-3 w-3" /> : null
}
<Link href="/problems"><span>Problems</span><LinkSpinner /></Link>
// On earlier versions, drive it yourself with a transition:
'use client'
const [isPending, startTransition] = useTransition()
const router = useRouter()
function go(href: string) {
startTransition(() => router.push(href))
}
<button onClick={() => go('/problems')} aria-busy={isPending}>
Problems {isPending && <Spinner />}
</button>
// The rule from the React track applies unchanged: acknowledge every
// interaction within 100ms, even when the work takes longer.Internationalisation
A locale segment, a dictionary loaded on the server, and middleware to pick a default. The routing is the easy part; deciding what actually needs translating is not.
- ✓The App Router has no built-in i18n routing — use a [locale] segment with middleware for the default
- ✓Loading dictionaries on the server sends only the active locale, unlike client-side i18n libraries
- ✓Declare alternates.languages or search engines treat translations as duplicate content
- ✓Intl formatting must be given an explicit locale, or the server default formats the user's numbers and dates
- ✓en-IN groups digits in lakhs, so number formatting is not just a separator swap
app/
[locale]/
layout.tsx
page.tsx // /en, /hi
problems/page.tsx // /en/problems
export async function generateStaticParams() {
return [{ locale: 'en' }, { locale: 'hi' }, { locale: 'mr' }]
}
// middleware.ts — send a bare URL to a locale
const LOCALES = ['en', 'hi', 'mr']
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
if (LOCALES.some((l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`))
return
const preferred = req.cookies.get('locale')?.value
?? pickFromAcceptLanguage(req.headers.get('accept-language'))
?? 'en'
return NextResponse.redirect(new URL(`/${preferred}${pathname}`, req.url))
}
// Tell search engines about the alternatives, or they treat the
// translations as duplicate content:
export async function generateMetadata({ params }) {
const { locale } = await params
return {
alternates: {
canonical: `/${locale}`,
languages: { en: '/en', hi: '/hi', mr: '/mr' },
},
}
}
<html lang={locale}> // and set this, for screen readers