Why Is My Next.js App So Slow in Production? Six Causes and Fixes
Founder & Lead Engineer, RAITHub
A Next.js app is usually slow in production for one of six reasons: pages render on every request by accident, database reads are not cached, queries run in a loop (N+1), too much JavaScript ships to the browser, images are not sized or optimised, or the server runs far from the database. Measure Time to First Byte and Largest Contentful Paint first; they tell you which one.
This guide works through each cause with the check that confirms it and the fix. The examples come from the RAITHub website, a Next.js 16 App Router site on Vercel with a Neon Postgres database. Its public pages are served as cached HTML and its hot queries are cached, which lets the database stay suspended between revalidations. Before that, the sitemap had no caching at all, and every crawler request ran database queries. The code comments call it the main database cost leak on the site.
How do you find out what is making Next.js slow?
Split the problem in two with two numbers. Time to First Byte (TTFB) is how long the server takes to start answering. Largest Contentful Paint (LCP) is when the main content appears on screen.
- TTFB: web.dev rates 0.8 seconds or less as good and more than 1.8 seconds as poor (web.dev: TTFB).
- LCP: aim for 2.5 seconds or less at the 75th percentile of page loads (web.dev: LCP).
A high TTFB means the server or its data is slow: causes 1, 2, 3 and 6 below. A good TTFB with a poor LCP means the browser is doing too much: causes 4 and 5. Measure on the deployed site, not in development, because Next.js renders every page on demand in development and never caches it.
One change in Next.js 16 matters here. The next build output no longer shows the "size" and "First Load JS" columns, which the Next.js team found inaccurate with Server Components; the upgrade guide recommends Lighthouse or real-user analytics instead (Next.js: upgrading to version 16).
| Symptom | Likely cause | First check |
|---|---|---|
| High TTFB on every page, even simple ones | 1. Dynamic rendering by accident | Does the root layout read cookies or headers? |
| High TTFB, database busy all day | 2. No caching or ISR | Do pages export revalidate? Are queries cached? |
| TTFB grows with the number of items on a page | 3. N+1 queries | Count queries per request in your database logs |
| Good TTFB, slow to become interactive | 4. Heavy client bundle | Run the bundle analyzer; count 'use client' files |
| Good TTFB, poor LCP on image-heavy pages | 5. Images | Is the hero image preloaded and sized? |
| Every query takes tens of milliseconds, even trivial ones | 6. Database far from the server | Compare the function region with the database region |
1. Is your page rendering dynamically by accident?
Often, yes. A page that could be served as cached HTML is rebuilt on every request because something in its tree reads request data.
Without Cache Components (an opt-in flag in Next.js 16, which this site does not use), a route renders dynamically when it reads cookies(), headers() or searchParams, sets dynamic = 'force-dynamic' or revalidate = 0, or makes an uncached data request. Next.js also no longer caches fetch by default: "By default, fetch requests are not cached" (Next.js: caching and revalidating).
The classic accident is a root layout that reads the session cookie to show a "Log in" or "My account" link. In that model, reading cookies in a layout makes every route under it dynamic, including pages that never needed a session. The Next.js caching docs describe this directly: in the new Cache Components model, reading cookies() inside a Suspense boundary "doesn't opt-in the whole route into dynamic rendering, the way the previous rendering model did" (Next.js: caching).
Fix: move request-specific reads out of shared layouts and into the component that needs them, or into a small client component that asks for the session after load. Keep marketing pages, blog posts and docs free of request data so they can be cached.
2. Are you caching pages and database reads?
If every visit and every crawler hit runs your queries, the database sets your response time. Two tools fix most of it: incremental static regeneration (ISR), which serves cached HTML and rebuilds it in the background on a timer, and a data cache for the queries themselves.
This site uses both. Public pages export a revalidate value, so they are served as cached HTML and rebuilt at most once a day:
// src/app/blog/page.tsx
// Serve cached HTML (ISR) instead of rendering per request. Refreshes daily, and
// admin publishes revalidate it at once.
export const revalidate = 86400
The sitemap's data comes from a cached query, so crawlers reading sitemap.xml do not hit the database each time:
// src/lib/queries.ts
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/db'
export const getSitemapContent = unstable_cache(
async () => {
const [caseStudies, blogPosts] = await Promise.all([
prisma.caseStudy.findMany({ where: { published: true, isSample: false }, select: { slug: true, updatedAt: true } }),
prisma.blogPost.findMany({ where: { published: true }, select: { slug: true, updatedAt: true, category: true } }),
])
return { caseStudies, blogPosts }
},
['sitemap-content-v2'],
{ revalidate: 3600 },
)
When an admin edits content, the API calls revalidatePath for the affected pages and the sitemap, so changes appear at once instead of waiting for the timer. The result, in the words of the code comment: the database "is queried at most once per revalidation window and can stay auto-suspended in between". The code does not claim a percentage saving and neither do we.
A note for new projects: in Next.js 16, unstable_cache "has been replaced by use cache", and the docs recommend opting into Cache Components (Next.js: unstable_cache). It still works in the previous model, which this site uses. Starting fresh, use 'use cache' with cacheLife instead.
3. Are N+1 queries hiding in your server components?
An N+1 query is one query for a list, then one more query for each item in it. Twenty posts with their authors becomes 21 round trips instead of 1 or 2. Server Components make it easy to write by accident, because each component fetches its own data.
// N+1: one query per post
const posts = await prisma.post.findMany({ take: 20 })
for (const post of posts) {
post.author = await prisma.user.findUnique({ where: { id: post.authorId } })
}
// Fixed: one query, with only the fields the page renders
const posts = await prisma.post.findMany({
take: 20,
select: { title: true, slug: true, author: { select: { name: true } } },
})
Three habits prevent most of it:
- Fetch relations in the query with
selectorinclude, not in a loop. - Run independent queries in parallel with
Promise.all, asgetSitemapContentdoes above, instead of awaiting them one after another. - Deduplicate within a render. If several components need the same record, wrap the loader in React's
cacheso it runs once per request; the Next.js docs show this for ORMs.
Then check the indexes behind those queries. The PostgreSQL indexing guide covers how to read a query plan.
4. Is too much JavaScript shipping to the browser?
Every 'use client' file and everything it imports is sent to the browser, downloaded, parsed and run. A heavy client bundle slows interactivity even when the server is fast.
- Measure first. Next.js 16.1 and later include a Turbopack bundle analyzer:
npx next experimental-analyze(Next.js: package bundling). On webpack, use@next/bundle-analyzer. - Push
'use client'down the tree. Make the button interactive, not the whole page. Work that only turns data into markup, such as syntax highlighting or markdown, can run in a Server Component and ship none of its library. - Import only what you use from large libraries. This site lists
lucide-react,framer-motionand@headlessui/reactunderoptimizePackageImportsinnext.config.tsso only the modules it uses are loaded. - Lazy-load what is below the fold, such as chat widgets and heavy charts.
The React Server Components explainer covers where the server and client boundary should sit.
5. Are images slowing down your LCP?
On most content pages the LCP element is an image, so an unsized or late-loading hero image sets the LCP on its own.
- Use
next/imagewith a width and height, orfillwith asizesvalue, so the browser downloads the right size. - Load the hero image early.
next/imagelazy-loads by default. In Next.js 16 thepriorityprop is deprecated in favour ofpreload, and the docs suggestloading="eager"orfetchPriority="high"for most cases (Next.js: Image). - Serve modern formats. This site sets
formats: ['image/avif', 'image/webp']and allows only its image host inremotePatterns.
6. Is your database far from your server?
Every query pays the network round trip between your function and your database. If they sit on different continents, each query adds that distance, and an N+1 page multiplies it.
Vercel runs functions in Washington, D.C. (iad1) by default for new projects and recommends choosing a region close to your data source (Vercel: function regions). If your database is in Europe or Asia, set the function region to match. Check this first when trivial queries are slow; it is a configuration change, not a code change.
A database that scales to zero adds one more factor. Neon, for example, suspends compute after 5 minutes of inactivity and takes a few hundred milliseconds to wake (Neon: scale to zero). The first request after a quiet spell pays that. Caching, as in cause 2, means fewer requests ever reach the database, so fewer visitors pay it.
Why RAITHub for Next.js performance?
Because the fixes above run on this site, and we diagnose from measurements, not guesses: TTFB and LCP first, then the one cause they point to.
- Cost and speed together. Caching pages and queries made this site faster and let its database stay asleep between revalidations.
- Checks on production builds. Caching and streaming behave differently in development, so we test what ships. The Next.js production checklist lists the rest.
- Fixed scope. Our Next.js development service covers performance audits and fixes, quoted in writing.
When you don't need us
- One cause is obvious. A missing
revalidate, a mismatched region or an unsized hero image is a short fix you can make yourself. - The slowness is a third-party script. Tag managers, chat widgets and ad scripts are usually a product decision, not an engineering one.
- Your host's analytics already name the slow route and query. Fix that query first and measure again.
For the metrics side in more depth, see the Core Web Vitals optimisation guide. If you want it diagnosed and fixed at a fixed price, see how a fixed-price fix works, or send us the slow URL for a free 15-minute technical audit.
Based on this site's Next.js 16 App Router code on Vercel and Neon, checked against the Next.js 16.3 documentation. Last reviewed: 29 September 2026.
Frequently asked questions
Why is Next.js fast locally but slow in production?
Usually because production adds network distance and real data. The server may be far from the database, queries that were instant on a small local dataset slow down on real volumes, and pages that could be cached are rendering per request.
Does Next.js cache fetch requests by default?
No. In current versions, fetch is not cached by default. Opt in with cache: 'force-cache', next.revalidate, or 'use cache' if you use Cache Components.
What is ISR in Next.js?
Incremental static regeneration serves a cached HTML page and rebuilds it in the background after a set time, such as export const revalidate = 86400 for once a day. Visitors get cached speed, and content still updates.
Is unstable_cache deprecated in Next.js 16?
The docs say it has been replaced by the use cache directive and recommend Cache Components. It still works in projects on the previous caching model, but new code should use 'use cache'.
What is a good TTFB for a Next.js site?
web.dev rates 0.8 seconds or less as good and over 1.8 seconds as poor. Cached pages served from a CDN usually come in well under that; pages rendered per request depend on your data and region.
How do I check my Next.js bundle size in version 16?
The build output no longer shows First Load JS. Use npx next experimental-analyze (16.1 and later) for Turbopack, @next/bundle-analyzer for webpack, and Lighthouse for what users actually download.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.