On this page
Performance work goes wrong in two directions. Either you optimize nothing and the app gets slow under real data, or you optimize everything up front and waste weeks on hot paths that were never hot. The middle path is to know the handful of optimizations that reliably matter for a data-backed Next.js app, apply them by default where they belong, and measure before reaching for anything exotic. This page is that handful: paginate large lists, cache on purpose, ship images at the right size, and fix the query patterns that turn one request into a hundred.
The Next.js 16 caching model is built on Cache Components: the use cache directive caches a function or component, cacheLife sets how long, and cacheTag plus revalidateTag / updateTag invalidate on demand. The rules below use that model.
Paginate, never load everything
The first endpoint that kills a growing app is the one that does SELECT * on a table that started with ten rows and now has a million. Decide the access pattern up front. Offset pagination (LIMIT/OFFSET) is fine for small, jump-to-page UIs; cursor pagination (a WHERE id > last keyset) is what scales, because the database seeks straight to the row instead of counting past everything before it.
Avoid
// Fine at 10 rows. At 100k it ships megabytes and stalls the page.
const posts = await db.post.findMany()Prefer
// Page by a stable key. The DB seeks to the cursor; constant cost
// per page no matter how deep you are.
const posts = await db.post.findMany({
take: 20,
...(cursor && { skip: 1, cursor: { id: cursor } }),
orderBy: { id: 'asc' },
})
const nextCursor = posts.at(-1)?.idThe rules
Whenan endpoint or query returns a list that can grow unbounded
Dopaginate it. Default to cursor (keyset) pagination for feeds and infinite scroll; use offset only for small, fixed page-number UIs.
const items = await db.item.findMany({
take: 20,
...(cursor && { skip: 1, cursor: { id: cursor } }),
orderBy: { createdAt: 'desc' },
})Whenthe same data is read on many requests and changes rarely
Docache it with the use cache directive and set a cacheLife. Do not recompute per request what is identical across requests.
import { cacheLife, cacheTag } from 'next/cache'
export async function getPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
return db.post.findMany({ take: 20, orderBy: { createdAt: 'desc' } })
}Whena mutation changes data you have cached
Doinvalidate by tag. Use updateTag in a Server Action when the user must see their own write immediately; use revalidateTag for background refresh where slight staleness is fine.
'use server'
import { updateTag } from 'next/cache'
export async function createPost(data: FormData) {
await db.post.create({ data: { title: data.get('title') } })
updateTag('posts') // read-your-own-writes: instant for the author
}Whena component reads request-time data (cookies, headers, searchParams) on an otherwise cacheable page
Dowrap just that component in a Suspense boundary so the static shell ships instantly and only the dynamic part streams. Do not make the whole page dynamic.
<Suspense fallback={<Skeleton />}>
<UserGreeting /> {/* reads cookies(); streams at request time */}
</Suspense>Whenyou render an image
Douse next/image with width/height and sizes so Next serves a fitted, modern-format image and reserves layout space (no CLS). Never ship a raw full-resolution img tag.
import Image from 'next/image'
<Image src={hero} alt="" width={1600} height={900}
sizes="(max-width: 768px) 100vw, 66vw" priority />Whenyou query a list and then query again inside the loop for each row
Dothat is an N+1. Fetch the relations in one query (join / include) or batch the lookups. One query for the page, not one per row.
// AVOID: 1 query for posts + N queries for authors.
// PREFER: include the relation so it is a single round trip.
const posts = await db.post.findMany({
take: 20,
include: { author: { select: { id: true, name: true } } },
})Whena query filters or sorts on a column
Domake sure that column is indexed. An unindexed WHERE or ORDER BY does a full table scan that gets slower with every row.
-- The filter/sort columns your hot queries use need an index.
CREATE INDEX idx_post_created_at ON post (created_at DESC);Whena heavy or rarely-used client component sits below the fold or behind an interaction
Docode-split it with next/dynamic so it is not in the initial bundle. Keep the first load lean.
import dynamic from 'next/dynamic'
// Loads only when rendered, not in the initial JS payload.
const Chart = dynamic(() => import('@/components/chart'))