Cheat SheetsNext.jsUX & Assets

UX & Assets — Cheat Sheet

Next.js · 6 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
UX & Assets
Next.js6 topicsQuick revision reference
1

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
metadata, generateMetadata, and a title template
// 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',
    },
  }
}
2

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
priority on the hero; sizes with fill
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.
3

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
Build-time download, preload, metric-matched fallback
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.
4

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
error.tsx, not-found.tsx, global-error.tsx
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.
5

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
useLinkStatus, or a transition you drive
// 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.
6

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
[locale] segment plus a middleware default
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
Learn this free with Aria, your AI tutor → AiCanCode.org/learn/nextjs