A fast site is not a luxury feature. People leave pages that feel slow, and search engines use page experience as a ranking signal. Next.js already does a lot of the work for you, but one careless import or one image without sizes is enough to undo it.
This entry is a practical tour of the techniques that make Next.js apps lighter and faster, written for Next.js 16 with the App Router. For each one you get what it is, why it helps, a code example, and when not to use it, because most performance advice turns harmful when it is applied everywhere. Every code block has notes on its lines: tap or hover a line to read them.
Why performance matters, and what to measure
Google sums up a page's user experience in three Core Web Vitals. Each has a "good" threshold, measured at the 75th percentile of real visits:
- LCP (Largest Contentful Paint): how long until the biggest image or text block on the first screen is painted. Good is 2.5 s or less. It is mostly about the network: HTML, render-blocking CSS, fonts and the hero image.
- INP (Interaction to Next Paint): how long the page takes to visually respond after a tap, click or key press, taken over the whole visit. Good is 200 ms or less. It is mostly about JavaScript on the main thread. INP replaced FID as a Core Web Vital in 2024.
- CLS (Cumulative Layout Shift): how much the content jumps around while it loads. Good is 0.1 or less. It is mostly about images without dimensions, late fonts and content injected above what you are reading.
Also watch TTFB (time to first byte) and FCP (first contentful paint). If TTFB is slow, no front-end trick will save LCP.
Measure first
Optimizing without measuring is guessing. Lab data comes from a test on one simulated device: repeatable, good for before-and-after comparisons. Field data comes from real visitors on real phones: it is what Google ranks on. You need both.
- Lighthouse (in Chrome DevTools) gives a lab score with simulated throttling. Run it on a production build (
next build && next start), never onnext dev, which is unoptimized on purpose. - PageSpeed Insights shows Lighthouse lab data plus real-user field data from the Chrome UX Report, when your site has enough traffic.
- The DevTools Performance panel shows long tasks, layout, paint and network frame by frame. Turn on CPU and network throttling to see what a mid-range phone sees.
useReportWebVitalssends your own field data from every visit to wherever you want.- A bundle analyzer shows what is inside your JavaScript.
Collecting real-user metrics with useReportWebVitals
What it is: a hook from next/web-vitals that calls your function with each metric (LCP, INP, CLS, FCP, TTFB) as the browser reports it. Why it helps: lab tests run on one device; this tells you what real visitors experience, page by page.
// app/_components/web-vitals.tsx'use client' import { useReportWebVitals } from 'next/web-vitals' type Metric = Parameters<Parameters<typeof useReportWebVitals>[0]>[0] // Defined outside the component, so the function never changes between renders.function report(metric: Metric) { const body = JSON.stringify({ name: metric.name, // 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB' | ... value: metric.value, rating: metric.rating, // 'good' | 'needs-improvement' | 'poor' id: metric.id, page: location.pathname, }) // sendBeacon survives the page being closed; fetch with keepalive is the fallback. if (!navigator.sendBeacon?.('/api/vitals', body)) { fetch('/api/vitals', { method: 'POST', body, keepalive: true }) }} export function WebVitals() { useReportWebVitals(report) return null}Line by line
- The hook runs in the browser, so this small component is a Client Component. It renders nothing, so it costs almost no JavaScript.
- The metric type is read from the hook's own signature, so it stays correct when Next.js updates it.
- The docs ask for a stable callback: a new function on each render would report the metrics collected so far again, and you would count them twice.
- Metrics like CLS and INP are final only when the page is hidden.
navigator.sendBeaconis built for exactly that moment and is not cancelled when the tab closes. - Mount
<WebVitals />once in the root layout. The layout itself stays a Server Component.
The other end is a small Route Handler that receives each metric. Start by logging them, and later aggregate them per page at the 75th percentile, the same way Google does.
// app/api/vitals/route.tsimport { NextResponse, type NextRequest } from 'next/server' type Vital = { name: string; value: number; rating: string; id: string; page: string }const NAMES = new Set(['LCP', 'INP', 'CLS', 'FCP', 'TTFB']) export async function POST(request: NextRequest) { const vital = (await request.json()) as Vital if (!NAMES.has(vital.name) || typeof vital.value !== 'number') { return new NextResponse(null, { status: 400 }) } console.log(JSON.stringify({ ...vital, at: Date.now() })) return new NextResponse(null, { status: 204 })}Line by line
sendBeaconsends the string as the request body, andrequest.json()parses it whatever the content type says.- This endpoint is public, so anything can post to it. Accept only the metric names you expect, and a number for the value, before storing anything.
- One JSON line per metric is easy to search in your host's logs. When the volume grows, write to a database or an analytics store instead, and keep only what you need: no IP addresses, no cookies.
204 No Content: the browser does not need an answer, so none is sent.
Reading the DevTools Performance panel
Lighthouse tells you that a page is slow; the Performance panel tells you why. A short routine that finds most problems:
- Open the production site, then DevTools → Performance. Set CPU to a 4× or 6× slowdown and Network to "Slow 4G", so your laptop behaves like a mid-range phone.
- Click the reload-and-record button. Recording a page load shows the Network track as a waterfall: look for anything that downloads at the same time as the HTML, CSS, fonts and hero image the first screen needs. Every one of those competes with your LCP.
- On the Main track, tasks with a red corner are long tasks (over 50 ms). Click one and open Bottom-Up to see which functions took the time. Long tasks during loading delay interactivity; long tasks after a click are what INP measures.
- For INP, record while you interact (type in a search box, open a menu) and look at the Interactions track. Each interaction is split into input delay, processing time and presentation delay, which tells you whether to make the handler cheaper or to move work out of the way.
- Find the LCP marker in the Timings track and check what was still loading or running just before it.
Seeing what is in your bundle
What it is: a treemap of every module in your client and server bundles. Why it helps: the biggest wins usually come from one surprisingly large dependency, and you cannot see it any other way. With webpack builds use @next/bundle-analyzer. With Turbopack (the default bundler in Next.js 16) there is a built-in analyzer, next experimental-analyze, available since 16.1 and still experimental.
// next.config.tsimport type { NextConfig } from 'next'import bundleAnalyzer from '@next/bundle-analyzer' const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true',}) const nextConfig: NextConfig = {} export default withBundleAnalyzer(nextConfig) // Webpack: ANALYZE=true next build --webpack// Turbopack: next experimental-analyzeLine by line
- Off by default, so normal builds are not slowed down. You switch it on only when you want a report.
@next/bundle-analyzeris a webpack plugin, so the build has to run with--webpackto produce its reports.- The Turbopack analyzer opens an interactive view where you can filter modules and trace why each one was imported. Add
--outputto write the result to.next/diagnostics/analyzefor later comparison.
Ship less JavaScript
Every kilobyte of JavaScript is downloaded, parsed and run before the page is interactive, and on a mid-range phone running it is often the slowest part. Less JavaScript helps LCP (less competition for bandwidth) and INP (less main-thread work) at once.
Server Components by default, "use client" at the leaves
What it is: in the App Router, every component is a Server Component unless a file starts with "use client". Server Components render on the server and send HTML plus a compact description of the result; their code never reaches the browser. Why it helps: "use client" marks a boundary, and everything imported below that boundary becomes client JavaScript. Put it at the top of a page and the whole page ships to the browser. Put it on the one button that needs state and only the button does.
// app/products/[id]/product-view.tsx (before)'use client' import { useState } from 'react'import { marked } from 'marked' type Product = { id: string; name: string; description: string } export function ProductView({ product }: { product: Product }) { const [liked, setLiked] = useState(false) return ( <article> <h1>{product.name}</h1> <div dangerouslySetInnerHTML={{ __html: marked.parse(product.description) as string }} /> <button onClick={() => setLiked(!liked)}>{liked ? 'Liked' : 'Like'}</button> </article> )}Line by line
- The whole view is a Client Component, so everything it renders and imports is downloaded by every visitor.
- A Markdown parser now ships to the browser just to turn a description into HTML once. It could have run on the server.
- The only interactive part of the page is one button with one piece of state.
// app/products/[id]/product-view.tsx (after)import { marked } from 'marked'import { LikeButton } from './like-button' type Product = { id: string; name: string; description: string } export async function ProductView({ product }: { product: Product }) { return ( <article> <h1>{product.name}</h1> <div dangerouslySetInnerHTML={{ __html: await marked.parse(product.description) }} /> <LikeButton productId={product.id} /> </article> )} // app/products/[id]/like-button.tsx// 'use client'// export function LikeButton({ productId }: { productId: string }) { ... }Line by line
- No
"use client": the view is now a Server Component. The Markdown parser runs on the server and is never sent to the browser. - A Server Component can be
asyncand await data or work directly. The page that renders it can fetch the product on the server too, close to the database, with no spinner and no API round trip from the browser. - Only
LikeButtonis a Client Component. Its props must be serializable (strings, numbers, plain objects), because they travel from server to browser. - The
"use client"line goes at the top oflike-button.tsx. Only this small file, and what it imports, becomes client JavaScript.
Pass Server Components into Client Components as children
What it is: a Client Component cannot import a Server Component, but it can receive one as children (or any other prop). The server renders that content and hands the Client Component the finished result. Why it helps: an interactive shell (tabs, an accordion, a modal, a carousel) no longer drags its contents into the client bundle. Only the shell's own code ships; the content stays server-rendered HTML.
// app/products/page.tsximport { Tabs } from './tabs'import { getReviews, getSpecs } from '@/lib/catalog' export default async function ProductPage() { const [specs, reviews] = await Promise.all([getSpecs(), getReviews()]) return ( <Tabs labels={['Specs', 'Reviews']}> <ul>{specs.map((s) => <li key={s}>{s}</li>)}</ul> <ul>{reviews.map((r) => <li key={r}>{r}</li>)}</ul> </Tabs> )}Line by line
- The data is fetched on the server, in parallel, by the page.
Tabsis a Client Component: it needs state to remember the open tab.- These lists are written in the Server Component and passed down as children. They arrive as rendered output: the code that built them, and any library it used, is never sent to the browser.
// app/products/tabs.tsx'use client' import { Children, useState, type ReactNode } from 'react' export function Tabs({ labels, children }: { labels: string[]; children: ReactNode }) { const [active, setActive] = useState(0) const panels = Children.toArray(children) return ( <div> <div role="tablist"> {labels.map((label, i) => ( <button key={label} role="tab" aria-selected={i === active} onClick={() => setActive(i)}> {label} </button> ))} </div> <div role="tabpanel">{panels[active]}</div> </div> )}Line by line
- The children are already-rendered content. The Client Component can arrange, show or hide them, but it does not know or care how they were made.
- Only the tab logic is client JavaScript: a few lines of state and buttons, however rich the panels are.
Keep server code out of the bundle with server-only
What it is: the server-only package (install it with npm install server-only) makes the build fail if a module is ever imported into client code. Why it helps: a helper that reads a secret and calls a paid API can end up in a Client Component through one innocent import. At best that adds weight to the bundle; at worst it leaks the secret's name and your API shape, or breaks at runtime because the variable is undefined in the browser.
// lib/billing.tsimport 'server-only' const API_KEY = process.env.BILLING_API_KEY export async function getInvoices(customerId: string) { const res = await fetch(`https://billing.example.com/invoices?customer=${customerId}`, { headers: { Authorization: `Bearer ${API_KEY}` }, }) if (!res.ok) throw new Error('Could not load invoices') return (await res.json()) as { id: string; total: number }[]}Line by line
- One line at the top. If any Client Component imports this file, directly or through other modules, the build stops with an error that names the import.
- Environment variables without the
NEXT_PUBLIC_prefix are only available on the server; in the browser this would beundefined.server-onlyturns that silent bug into a build error.
Lazy-load heavy Client Components with next/dynamic
What it is: next/dynamic loads a component's code in a separate chunk, only when it is rendered. Why it helps: a chart library, a code editor or a video player can be tens of kilobytes of JavaScript that most visitors never use. Splitting it out keeps it off the first load.
// app/dashboard/sales-panel.tsx'use client' import dynamic from 'next/dynamic'import { useState } from 'react' const SalesChart = dynamic(() => import('./sales-chart'), { ssr: false, loading: () => <div className="h-72 animate-pulse rounded-lg bg-neutral-800" />,}) export function SalesPanel() { const [open, setOpen] = useState(false) return ( <section> <button onClick={() => setOpen(true)}>Show sales chart</button> {open && <SalesChart />} </section> )}Line by line
- The chart and its library go into their own chunk. The page's first load no longer carries them.
ssr: falseskips server rendering, for components that only make sense in the browser (they readwindow, measure the DOM, draw on a canvas). It is only allowed in Client Components; in a Server Component it is an error.- A placeholder with the same height as the chart, so nothing jumps when the chunk arrives. That keeps CLS at zero.
- The chunk is requested the first time the chart is rendered, here after a click. It could also be on scroll into view or when the browser is idle.
The docs note one limit worth knowing: when a Server Component dynamically imports a Client Component, automatic code splitting is currently not supported. Do your dynamic() calls inside Client Components, as above.
Import a library at the moment it is needed
What it is: next/dynamic is for components. For plain libraries (a search index, a PDF exporter, a Markdown renderer, a date picker's locale data) a dynamic import() inside an event handler does the same job. Why it helps: the library is fetched only by visitors who actually use the feature, and only when they show intent, such as focusing the search box.
// app/components/search-box.tsx'use client' import { useRef, useState } from 'react' type Search = (query: string) => string[] export function SearchBox() { const searchRef = useRef<Promise<Search> | null>(null) const [results, setResults] = useState<string[]>([]) function load() { searchRef.current ??= import('./fuzzy-search').then((m) => m.createSearch()) return searchRef.current } async function onChange(query: string) { const search = await load() setResults(search(query)) } return ( <div> <input type="search" placeholder="Search posts" onFocus={() => void load()} onChange={(e) => onChange(e.target.value)} /> <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul> </div> )}Line by line
- One function both starts the download and returns it, so every caller waits on the same promise.
??=assigns only when the ref is still empty: the module is imported once, however many times focus and typing callload(). The bundler puts./fuzzy-searchand everything it imports in a separate chunk.- If someone types before the chunk has arrived, the first search simply waits for it; nothing is lost.
- Focus is the signal of intent: the download starts while the visitor is still reaching for the keyboard, so it is usually ready by the first keystroke.
Avoid barrel-file imports, and know optimizePackageImports
What it is: a barrel file is an index.ts that re-exports everything from a folder. Importing one function from a barrel can pull the whole folder into the bundle, or at least make the bundler process every file in it. Why it helps: importing from the exact file keeps the bundle to what you use and makes builds faster.
// app/components/price.tsx// Before: one name from a barrel that re-exports 40 helpers, some with heavy dependencies// import { formatPrice } from '@/utils' // After: straight from the file that defines itimport { formatPrice } from '@/utils/format-price' export function Price({ cents }: { cents: number }) { return <span>{formatPrice(cents)}</span>}Line by line
- Whether this pulls in all 40 helpers depends on how they are written: one helper with a side effect at the top of its file is enough to defeat tree-shaking.
- A direct import gives the bundler exactly one file to follow. It is always correct, whatever the other files do.
For third-party libraries with hundreds of exports, Next.js has experimental.optimizePackageImports, which rewrites such imports to load only the modules you use. It is experimental, and many popular libraries are already optimized by default (among them lucide-react, date-fns, lodash-es, @mui/material, @heroicons/react, recharts and react-icons). Add a library to the list only if the analyzer shows it is not being trimmed.
// next.config.tsimport type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { optimizePackageImports: ['my-icon-library'], },} export default nextConfigLine by line
- Only for packages that export many modules and are not in the default list. It is an experimental option, so check it again when you upgrade.
Replace heavy libraries with lighter ones, or with the platform
What it is: checking whether a dependency earns its weight. Why it helps: the browser now does natively much of what we used to install libraries for. Dates, numbers, currency, relative time and list formatting are built into Intl. Simple animations are CSS. fetch replaces most HTTP clients. Deep-cloning is structuredClone.
// lib/format.ts (no date or number library needed)const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' })const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })const relative = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) export const formatDate = (d: Date) => dateFormat.format(d) // "22 Oct 2026"export const formatMoney = (cents: number) => money.format(cents / 100) // "$12.50" export function timeAgo(d: Date, now = Date.now()) { const days = Math.round((d.getTime() - now) / 86_400_000) return relative.format(days, 'day') // "yesterday", "in 3 days"}Line by line
- Formatters are created once at module level and reused. Creating an
Intlformatter is the expensive part; calling.format()is cheap. numeric: 'auto'gives natural words like "yesterday" and "tomorrow" instead of "1 day ago".
Keep interactions fast (INP)
Shipping less JavaScript makes the page ready sooner; INP is about what happens after, each time someone taps or types. The browser cannot paint the response to an input while the main thread is busy, so the goal is simple: keep each piece of work triggered by an interaction short, and let the urgent part (the character appearing in the input, the button looking pressed) paint first.
Keep typing responsive with useDeferredValue
What it is: React's useDeferredValue gives you a copy of a value that is allowed to lag behind. React renders the urgent update (the input) first, then re-renders the expensive part with the new value in the background, and abandons that work if another keystroke arrives. Why it helps: filtering a long list on every keystroke can block typing; with a deferred value the input stays smooth while the list catches up.
// app/components/product-filter.tsx'use client' import { memo, useDeferredValue, useState } from 'react' type Product = { id: string; name: string } export function ProductFilter({ products }: { products: Product[] }) { const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) const stale = query !== deferredQuery return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter products" /> <div style={{ opacity: stale ? 0.6 : 1 }}> <ResultList products={products} query={deferredQuery} /> </div> </div> )} const ResultList = memo(function ResultList({ products, query }: { products: Product[]; query: string }) { const q = query.toLowerCase() return ( <ul> {products.filter((p) => p.name.toLowerCase().includes(q)).map((p) => <li key={p.id}>{p.name}</li>)} </ul> )})Line by line
- During typing,
deferredQuerykeeps the previous value until React has time to render the new one. - The input is driven by
query, the urgent value, so every character appears immediately. - A cheap hint that the list is catching up: dim it while the deferred value is behind. No spinner needed.
memomatters here: without it the list would re-render with every keystroke anyway, because its parent does. With it, the list re-renders only whendeferredQuerychanges, in the background. (With the React Compiler on, this memoization is added for you.)
Its sibling startTransition (or the useTransition hook, which also gives you an isPending flag) does the same for a state update you trigger yourself: wrap the expensive setState call, such as switching a large tab or applying a filter, and React keeps the page responsive while it renders.
Break up long tasks
What it is: a long loop in an event handler (parsing a file, sorting thousands of rows, building a chart) is one long task that blocks every input until it ends. Splitting it into chunks and yielding to the browser between them lets clicks and paints happen in the gaps. Why it helps: the total work is the same, but no single task is long enough to hurt INP.
// lib/yield.tstype SchedulerWithYield = { yield?: () => Promise<void> } export function yieldToMain(): Promise<void> { const scheduler = (globalThis as { scheduler?: SchedulerWithYield }).scheduler if (scheduler?.yield) return scheduler.yield() return new Promise((resolve) => setTimeout(resolve, 0))} export async function processInChunks<T>(items: T[], work: (item: T) => void, budgetMs = 40) { let start = performance.now() for (const item of items) { work(item) if (performance.now() - start > budgetMs) { await yieldToMain() start = performance.now() } }}Line by line
scheduler.yield()lets the browser handle pending input and paint, then resumes your code ahead of other queued work. It is supported in Chromium-based browsers but not everywhere yet, so it is feature-detected.- The fallback:
setTimeout(…, 0)also gives the browser a turn, but your code waits behind any other queued tasks. - Yield only when the chunk has used up its time budget (here 40 ms, under the 50 ms long-task threshold), not after every item: yielding has a cost too.
Rendering strategies
Where and when a page is rendered decides its TTFB and much of its LCP. The right choice is made per route, sometimes per component.
Static rendering and ISR
What it is: a static route is rendered once at build time and served as a file from the CDN. Incremental Static Regeneration (ISR) re-renders it in the background after a time you choose, or when you tell it to. Why it helps: a static page has the fastest possible TTFB, because no code runs per request.
// app/blog/[slug]/page.tsximport { notFound } from 'next/navigation'import { getAllSlugs, getPost } from '@/lib/posts' export const revalidate = 3600 export async function generateStaticParams() { const slugs = await getAllSlugs() return slugs.map((slug) => ({ slug }))} export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params const post = await getPost(slug) if (!post) notFound() return ( <article> <h1>{post.title}</h1> <p>{post.excerpt}</p> </article> )}Line by line
- Re-render this page at most once an hour. The value must be a plain number that the build can read (
3600works,60 * 60does not). Visitors always get the cached page instantly; the refresh happens in the background. - Lists the slugs to build ahead of time. Slugs that are not listed are rendered on their first request and then cached as well.
- Since Next.js 15,
paramsis a Promise, so it is awaited.
For content that changes when someone edits it, revalidate on demand instead of on a timer: tag the data, then call revalidateTag (shown below in the caching section) or revalidatePath from the code that saves the change.
On-demand revalidation from a CMS or admin
What it is: a Route Handler that your CMS, admin panel or webhook calls after content changes, which then calls revalidateTag or revalidatePath. Why it helps: pages can be cached for a long time (or forever) and still update within seconds of an edit, instead of picking a short timer that re-renders constantly just in case.
// app/api/revalidate/route.tsimport { revalidatePath, revalidateTag } from 'next/cache'import { NextResponse, type NextRequest } from 'next/server' export async function POST(request: NextRequest) { if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) { return NextResponse.json({ ok: false }, { status: 401 }) } const { tag, path } = (await request.json()) as { tag?: string; path?: string } if (tag) revalidateTag(tag, 'max') if (path) revalidatePath(path) return NextResponse.json({ ok: true, tag, path })}Line by line
- A shared secret, sent in a header and kept in an environment variable on both sides. Without it, anyone could force your pages to re-render as often as they like.
- Refreshes every cached fetch that carries this tag, on whichever pages use it. With
'max', the next visitor still gets the old version instantly while the new one is built. - Refreshes one route (for example
/blog/my-post) when you know the page but not the tags behind it.
This very site works this way: when an entry is saved in the admin, the API calls the site's revalidate endpoint, and the journal pages update without waiting for a timer.
Before / After: the layout that made every page dynamic
What it is: reading cookies() or headers() in a shared layout. Why it hurts: in the model without Cache Components, one request-time read anywhere in a route makes the whole route dynamic, so every page under that layout is rendered on every request, and loses its CDN-cached HTML, just to print a name in the header.
// app/(shop)/layout.tsx (before)import type { ReactNode } from 'react'import { cookies } from 'next/headers' export default async function ShopLayout({ children }: { children: ReactNode }) { const name = (await cookies()).get('user-name')?.value return ( <> <header>Hello, {name ?? 'guest'}</header> {children} </> )}Line by line
- A request-time API in the layout: from now on, nothing under
(shop)can be prerendered. - Product pages, category pages and the home page of the shop all become dynamic because of one greeting.
// app/(shop)/greeting.tsx (after)'use client' import { useSyncExternalStore } from 'react' const subscribe = () => () => {}const readName = () => document.cookie.match(/(?:^|; )user-name=([^;]*)/)?.[1] ?? null export function Greeting() { const name = useSyncExternalStore(subscribe, readName, () => null) return <span>Hello, {name ? decodeURIComponent(name) : 'guest'}</span>} // The layout renders <header><Greeting /></header> and no longer awaits cookies(),// so every page under it can be static again.Line by line
- The cookie does not change while the page is open, so there is nothing to subscribe to.
- On the server and during hydration the third function is used (no name yet), so server and client HTML match; React then reads the real cookie in the browser.
- The personal part moved to the client and the expensive part, the pages, went back to static.
With Cache Components you have a second option: keep the server-side cookies() read, but move it into a small component wrapped in <Suspense>. The layout and pages then stay in the static shell, and only the greeting streams in.
Streaming with loading.tsx and Suspense
What it is: instead of waiting for all data before sending anything, the server sends the page's shell at once and streams slower parts in as they finish. loading.tsx wraps a whole route segment in a fallback; <Suspense> does the same for any part of a page. Why it helps: the visitor sees the layout and the fast content immediately, and one slow query no longer holds the whole page hostage.
// app/dashboard/page.tsximport { Suspense } from 'react'import { getRevenue, getUserName } from '@/lib/data' export default async function Dashboard() { const name = await getUserName() return ( <main> <h1>Welcome back, {name}</h1> <Suspense fallback={<p className="h-40">Loading revenue...</p>}> <Revenue /> </Suspense> </main> )} async function Revenue() { const revenue = await getRevenue() // slow query return <p>Revenue this month: {revenue}</p>} // app/dashboard/loading.tsx// export default function Loading() { return <p>Loading dashboard...</p> }Line by line
- Fast data is awaited at the top, so the heading is in the very first HTML.
- The fallback is sent in the first response. Give it the same size as the real content, so swapping it in does not shift the layout.
- The slow query runs inside the boundary. When it finishes, its HTML streams into the same response and replaces the fallback.
- A
loading.tsxfile next topage.tsxwraps the whole page in a Suspense boundary for you, and it is shown instantly on navigation.
Cache Components and Partial Prerendering
What it is: in Next.js 16, Cache Components (cacheComponents: true in next.config.ts) changes the model. Data fetching is dynamic by default, and you mark what can be cached with the "use cache" directive. Next.js then prerenders a static shell for every route and streams the dynamic parts into it. This is Partial Prerendering (PPR): it used to be the experimental experimental.ppr flag and is now the default behavior under cacheComponents. The old flag has been removed. Why it helps: a page no longer has to be all static or all dynamic. The shell comes from the CDN instantly, and only the personal or live parts wait for the server.
// app/shop/page.tsx (with cacheComponents: true in next.config.ts)import { Suspense } from 'react'import { cookies } from 'next/headers'import { cacheLife } from 'next/cache' async function getCategories(): Promise<string[]> { 'use cache' cacheLife('hours') const res = await fetch('https://api.example.com/categories') return res.json()} export default async function Shop() { const categories = await getCategories() return ( <main> <nav>{categories.map((c) => <a key={c} href={`/shop/${c}`}>{c}</a>)}</nav> <Suspense fallback={<p>Loading your cart...</p>}> <Cart /> </Suspense> </main> )} async function Cart() { const id = (await cookies()).get('cart')?.value return <p>{id ? 'Your cart is saved.' : 'Your cart is empty.'}</p>}Line by line
"use cache"makes this function's result cacheable. Its arguments become part of the cache key, so different inputs are cached separately.cacheLifesets how long the result stays fresh, using a built-in profile (seconds,minutes,hours,days,weeks,max) or your own.- Because the categories are cached, the navigation becomes part of the prerendered static shell.
- The cart reads cookies, which only exist at request time. Inside
<Suspense>it streams in after the shell. Without a boundary, Next.js tells you during development that dynamic data needs one. cookies()is a request-time API. Reading it is what marks this component as dynamic.
Data fetching and caching
How you fetch data decides how fast the server answers. These examples use the model without Cache Components, which most existing apps run on.
Fetch caching and revalidation
What it is: Next.js extends fetch on the server with caching options. Since Next.js 15 the default is not cached: in a statically prerendered route the fetch runs once at build time, and in a dynamic route on every request. You opt in with cache: 'force-cache', set a lifetime with next: { revalidate }, and label data with next: { tags } so you can refresh it on demand.
// lib/products.tsexport type Product = { id: string; name: string; price: number } export async function getProducts(): Promise<Product[]> { const res = await fetch('https://api.example.com/products', { next: { revalidate: 600, tags: ['products'] }, }) if (!res.ok) throw new Error('Failed to load products') return res.json()}Line by line
- Cache the response for up to 10 minutes and label it
products. Every fetch with that tag can be refreshed together. - Only
200responses are stored in the cache, and throwing here lets the nearesterror.tsxshow a proper error instead of rendering bad data.
// app/admin/actions.ts'use server' import { revalidateTag } from 'next/cache'import type { Product } from '@/lib/products' export async function saveProduct(product: Product) { await fetch(`https://api.example.com/products/${product.id}`, { method: 'PUT', body: JSON.stringify(product), }) revalidateTag('products', 'max')}Line by line
- A file of Server Actions: every exported function runs on the server, and Client Components can import and call them, for example from a form.
- Marks all
productsdata as stale. With the recommended'max'profile, the next request is still served the stale data while it triggers the refresh in the background. When the person who made the change must see it at once, useupdateTaginstead, which works only in Server Actions.
Request memoization
What it is: during one server render, identical GET fetches (same URL and options) run only once, even when several components call them. For database queries and other non-fetch work, wrap the function in React's cache() to get the same effect. Why it helps: you can fetch data where it is used, in the layout, the page and generateMetadata, without paying for it three times.
// lib/user.tsimport { cache } from 'react'import { db } from '@/lib/db' export const getUser = cache(async (id: string) => { return db.user.findUnique({ where: { id } })}) // app/profile/[id]/layout.tsx, page.tsx and generateMetadata can all call// getUser(id): the query runs once per request.Line by line
cache()remembers the result for each set of arguments for the duration of one request. It is not a persistent cache: the next request runs the query again.- This is what makes "fetch where you need it" practical: components stay independent without multiplying database queries.
Parallel fetching instead of waterfalls
What it is: a waterfall is when one request waits for another it does not depend on. Why it helps: three independent 200 ms requests take 600 ms in a row but about 200 ms together. This is one of the most common reasons for a slow TTFB in App Router apps.
// app/artist/[id]/page.tsximport { getAlbums, getArtist, getTours } from '@/lib/music' export default async function ArtistPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params // Before: each await waits for the previous one, though none depends on another // const artist = await getArtist(id) // const albums = await getAlbums(id) // const tours = await getTours(id) // After: all three start at once const [artist, albums, tours] = await Promise.all([ getArtist(id), getAlbums(id), getTours(id), ]) return ( <main> <h1>{artist.name}</h1> <p>{albums.length} albums, {tours.length} upcoming shows</p> </main> )}Line by line
- Three awaits in a row: the page takes as long as all three requests added together.
- Calling the functions starts the requests;
Promise.allwaits for all of them at once. The page now takes as long as the slowest request alone.
Start a fetch on the server, finish it in a Client Component
What it is: a Server Component can start a request without awaiting it and pass the promise to a Client Component, which unwraps it with React's use() inside a <Suspense> boundary. Why it helps: the request starts as early as possible, on the server, but the page is not held back by it. The interactive chart or table renders as soon as its data streams in, and there is no client-side fetch, no useEffect and no loading state to manage by hand.
// app/dashboard/page.tsximport { Suspense } from 'react'import { getStats } from '@/lib/stats'import { StatsChart } from './stats-chart' export default function Dashboard() { const statsPromise = getStats() return ( <main> <h1>Dashboard</h1> <Suspense fallback={<div className="h-64">Loading chart...</div>}> <StatsChart dataPromise={statsPromise} /> </Suspense> </main> )}Line by line
- No
await: the request starts now, during the server render, and the page carries on. - A promise is a valid prop from a Server to a Client Component. The heading and the fallback are sent at once; the chart's data follows in the same response when the promise resolves.
// app/dashboard/stats-chart.tsx'use client' import { use } from 'react' type Stats = { revenue: number; orders: number } export function StatsChart({ dataPromise }: { dataPromise: Promise<Stats> }) { const stats = use(dataPromise) return ( <p> {stats.orders} orders, {stats.revenue} revenue </p> )}Line by line
use()suspends the component until the promise resolves, so the nearest<Suspense>fallback shows meanwhile. Unlike hooks, it may be called inside conditions.
Caching database queries with unstable_cache (previous model)
What it is: fetch has caching built in, but database clients and SDKs do not. Without Cache Components, unstable_cache wraps any async function and stores its result across requests, with a lifetime and tags like fetch. Its status matters: the docs say it has been replaced by "use cache" in Next.js 16, so use it only in apps that have not moved to Cache Components yet.
// lib/authors.tsimport { unstable_cache } from 'next/cache'import { db } from '@/lib/db' export const getAuthor = unstable_cache( async (id: string) => db.user.findUnique({ where: { id } }), ['author'], { revalidate: 3600, tags: ['authors'] },)Line by line
- Any async work: a database query, an SDK call, an expensive calculation. The function's arguments (here
id) are part of the cache key automatically. - Extra key parts, to tell this cache apart from other functions that might take the same arguments.
- Fresh for an hour, and refreshed at once whenever
revalidateTag('authors', 'max')is called after an author is edited.
Asset optimization
On most pages the largest bytes are not JavaScript but images and fonts, and the LCP element is usually an image or a heading in a web font.
next/image: sizes, preload, placeholder and formats
What it is: <Image> from next/image resizes images on demand, serves modern formats, lazy-loads by default and reserves space so nothing shifts. Why it helps: a phone downloads a phone-sized image instead of a 3000 px original, and CLS from images disappears. Two props matter most: sizes, which tells the browser how wide the image will be drawn, and preload, for the one image that is the LCP.
// app/page.tsximport Image from 'next/image'import hero from './hero.jpg' export default function Home() { return ( <main> <Image src={hero} alt="Tents by a lake at sunrise" preload sizes="100vw" placeholder="blur" className="h-[70vh] w-full object-cover" /> <div className="relative aspect-[4/3] w-full md:w-1/3"> <Image src="https://images.example.com/gear.jpg" alt="Camping gear laid out on a table" fill sizes="(min-width: 768px) 33vw, 100vw" className="object-cover" /> </div> </main> )}Line by line
- A static import gives Next.js the width, height and a tiny blurred preview at build time, so the space is reserved before the image loads.
preloadadds a<link rel="preload">in the<head>, so the browser starts fetching the hero before it even reaches the<img>. It replacespriority, which is deprecated since Next.js 16. Use it on one image only: the LCP element.- Without
sizes, the browser assumes the image is as wide as the viewport and Next.js generates only a small 1x/2xsrcset. With it you get a fullsrcset, and each device picks the smallest file that is sharp enough. - Shows the blurred preview while the full image loads. For static imports of jpg, png, webp or avif, the
blurDataURLis generated automatically. fillmakes the image cover its parent, which must be positioned and have a size (hererelativeplus an aspect ratio). Remote URLs must be allowed inimages.remotePatterns.- The image is a third of the screen on wide screens and full width on phones. This line is how a phone avoids downloading the desktop file.
In next.config.ts, images.formats defaults to ['image/webp']; set ['image/avif', 'image/webp'] to serve AVIF where the browser supports it (smaller files, slower to encode on the first request). Since Next.js 16, images.qualities is a required allowlist, [75] by default, so any other quality value must be added there.
The image settings worth knowing live in next.config.ts:
// next.config.tsimport type { NextConfig } from 'next' const nextConfig: NextConfig = { images: { formats: ['image/avif', 'image/webp'], qualities: [60, 75], remotePatterns: [new URL('https://images.example.com/products/**')], },} export default nextConfigLine by line
- AVIF first for browsers that accept it, WebP for the rest. AVIF files are usually smaller but slower to encode, which only matters the first time each size is requested.
- The allowlist of
qualityvalues (required since Next.js 16). A lower quality for large background photos, 75 for everything else. - Only images from this host and path can go through the optimizer. Be as specific as you can: an open pattern lets anyone use your server to resize their images.
Art direction: a different crop for phones
What it is: sizes picks a size of one image; sometimes a phone needs a different image, such as a tall crop of a wide hero. getImageProps gives you the optimized srcSet for each source, so you can build a <picture> element yourself. Why it helps: phones download a smaller, tighter crop instead of a scaled-down landscape where the subject is tiny.
// app/components/hero-picture.tsximport { getImageProps } from 'next/image' export function HeroPicture() { const common = { alt: 'Camp by the lake at sunrise', sizes: '100vw' } const { props: { srcSet: wide }, } = getImageProps({ ...common, src: '/hero-wide.jpg', width: 1600, height: 900 }) const { props: { srcSet: tall, ...rest }, } = getImageProps({ ...common, src: '/hero-tall.jpg', width: 800, height: 1200 }) return ( <picture> <source media="(min-width: 768px)" srcSet={wide} /> <img {...rest} srcSet={tall} loading="eager" fetchPriority="high" style={{ width: '100%', height: 'auto' }} /> </picture> )}Line by line
- Each call returns the props
<Image>would have rendered, including asrcSetof optimized sizes for that file. - Screens 768 px and wider use the wide crop. The browser evaluates
<source>elements in order and downloads only the one that matches. - Phones use the tall crop. Because this is the hero, it loads eagerly and at high priority, which is what the docs recommend in most cases instead of a preload.
next/font: self-hosted fonts without layout shift
What it is: next/font downloads Google Fonts (or loads your local font files) at build time and serves them from your own domain. The browser never contacts Google. It also generates a fallback font adjusted to the same metrics. Why it helps: no extra DNS lookup or connection to a font host, and text does not jump when the real font arrives, which protects CLS.
// app/layout.tsximport type { ReactNode } from 'react'import { Inter } from 'next/font/google'import localFont from 'next/font/local' const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter',}) const brand = localFont({ src: './fonts/brand-display.woff2', variable: '--font-brand', preload: false,}) export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en" className={`${inter.variable} ${brand.variable}`}> <body>{children}</body> </html> )}Line by line
- Only the Latin characters are preloaded. Inter is a variable font, so no
weightlist is needed: one file covers every weight. swapis the default: text shows at once in the adjusted fallback and switches when the font arrives.- A display face used on only a few pages does not need to be preloaded on every page. Turning preload off saves that download on pages that never use it.
- The fonts are exposed as CSS variables, so stylesheets or Tailwind can use them wherever they are needed.
With Tailwind CSS v4, the CSS variables from next/font become theme fonts in your stylesheet, so font-sans and font-display work everywhere with the self-hosted files:
/* app/globals.css */@import 'tailwindcss'; @theme inline { --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; --font-display: var(--font-brand), Georgia, serif;}Line by line
inlinematters: it tells Tailwind to use the variable's value where the class is used, which is what you need when the theme value itself refers to another variable.- The system fonts after the variable are the fallback while the web font loads, and if it ever fails to load.
next/script: loading third-party scripts on your terms
What it is: <Script> from next/script loads a script with a chosen strategy. afterInteractive (the default) loads after some hydration. lazyOnload waits for browser idle time, after all page resources. beforeInteractive is for the rare script that must run before the page hydrates, and must sit in the root layout. worker, which runs a script in a web worker, is experimental. Why it helps: chat widgets, analytics and tag managers are often the heaviest JavaScript on a page, and none of them is needed for the first paint.
// app/layout.tsximport type { ReactNode } from 'react'import Script from 'next/script' export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en"> <body> {children} <Script src="https://analytics.example.com/script.js" strategy="afterInteractive" /> <Script src="https://widget.example.com/chat.js" strategy="lazyOnload" /> </body> </html> )}Line by line
- Analytics should see the visit early, but not before your own code: after hydration is the right moment.
- A chat bubble can wait until the page is idle. Visitors who leave in the first seconds never download it at all.
Navigation and network
Link prefetching
What it is: <Link> prefetches the route it points to when the link enters the viewport, so the next page opens instantly. Static routes are prefetched in full; dynamic routes only down to their nearest loading.tsx, or not at all. Why it matters: prefetching is great for the next click and bad for the first load when it is overused, because every prefetch is a download competing with the current page.
// app/components/archive-list.tsximport Link from 'next/link' type Post = { slug: string; title: string } export function ArchiveList({ posts }: { posts: Post[] }) { return ( <ul> {posts.map((post) => ( <li key={post.slug}> <Link href={`/blog/${post.slug}`} prefetch={false}> {post.title} </Link> </li> ))} </ul> )}Line by line
- With hundreds of links on screen, viewport prefetching would download hundreds of routes. With
prefetch={false}the route is still prefetched on hover, so a deliberate click stays fast.
A trap from this very site: a closed menu that is hidden but still laid out (with inert or visibility, not display: none) counts as "in the viewport", so its links, and the fonts of the pages behind them, were prefetched on every first load. Enabling prefetch only when the menu opens (prefetch={open ? null : false}) fixed it. Apps on Cache Components can also enable partialPrefetching: true, which prefetches one reusable shell per route instead of one per visible link.
Show that a navigation is happening with useLinkStatus
What it is: when a route is not prefetched (or is dynamic without a loading.tsx), a click can take a moment, and nothing on screen says so. The useLinkStatus hook tells a component inside a <Link> whether that link's navigation is pending. Why it helps: perceived performance. A small hint that appears at once makes a wait feel shorter and stops people clicking twice.
// app/components/nav-link.tsx'use client' import Link, { useLinkStatus } from 'next/link'import type { ReactNode } from 'react' function PendingDot() { const { pending } = useLinkStatus() return <span aria-hidden className={pending ? 'dot dot--pending' : 'dot'} />} export function NavLink({ href, children }: { href: string; children: ReactNode }) { return ( <Link href={href} prefetch={false}> {children} <PendingDot /> </Link> )}Line by line
- It must be called in a component rendered inside the
<Link>; it reports that link's state. - Keep the hint subtle and delay its appearance slightly in CSS (for example with an animation delay of about 100 ms), so fast navigations do not flash it.
- Here prefetching is off, the case the docs name for this hook: navigation then waits for the server, and the dot shows that it is.
Runtimes, Proxy and keeping request-time code light
Two things changed in Next.js 16 that make older advice out of date. The Edge runtime is deprecated for routes: export const runtime = 'edge' should be removed, and Node.js is the default and the recommendation. And middleware.ts is deprecated and renamed to proxy.ts. Proxy always runs on Node.js, and setting runtime in it throws an error.
What it is: Proxy runs before every matching request. Why it matters: anything slow there (a database call, a large import) is added to the TTFB of every page it matches. Without a matcher, it even runs for static files and image requests.
// proxy.tsimport { NextResponse, type NextRequest } from 'next/server' export function proxy(request: NextRequest) { const hasSession = request.cookies.has('session') if (!hasSession) { return NextResponse.redirect(new URL('/login', request.url)) } return NextResponse.next()} export const config = { matcher: ['/dashboard/:path*', '/account/:path*'],}Line by line
- The function is named
proxy(it wasmiddlewarebefore Next.js 16). The codemodnpx @next/codemod@canary middleware-to-proxy .renames it for you. - A cheap check only: is there a session cookie? Verifying the session itself (a database lookup, token validation) belongs in the page or Server Action, which also keeps your authorization where the docs recommend it.
- Run only on the paths that need it. Every other request, including
_next/staticand images, skips Proxy entirely.
Compression and CDN caching headers
Hashed files under /_next/static/ are already served with public, max-age=31536000, immutable. Static and ISR pages get s-maxage headers that a CDN understands. next start compresses responses with gzip unless you turn compress off (do that only when a proxy in front of it already compresses, for example with brotli). What Next.js cannot know is how often your files in public/ change: they are served without a long cache lifetime, so every visit asks for them again.
// next.config.tsimport type { NextConfig } from 'next' const nextConfig: NextConfig = { async headers() { return [ { source: '/fonts/:path*', headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }], }, { source: '/images/:path*', headers: [{ key: 'Cache-Control', value: 'public, max-age=86400, stale-while-revalidate=604800' }], }, ] },} export default nextConfigLine by line
- Safe only for files that get a new name whenever their content changes. Then the browser never needs to ask again.
- Files that keep their name when edited get a shorter life: a day fresh, then served stale for up to a week while being refreshed in the background.
Build and dev tooling
Turbopack
What it is: a Rust-based bundler built into Next.js, the default for next dev and next build since Next.js 16. Why it helps: faster startup, updates and builds. It does not change what users download, but a quicker loop means you measure and fix more often. If your build depends on webpack (a custom config, or a plugin like @next/bundle-analyzer), use --webpack.
React Compiler
What it is: a build-time compiler that memoizes components and values automatically, so you rarely need useMemo, useCallback or React.memo by hand. In Next.js 16 it is enabled with the top-level reactCompiler: true option (it is no longer under experimental) after installing babel-plugin-react-compiler. Why it helps: fewer unnecessary re-renders, which can improve INP in interaction-heavy UIs.
// next.config.ts (after: npm install -D babel-plugin-react-compiler)import type { NextConfig } from 'next' const nextConfig: NextConfig = { reactCompiler: true,} export default nextConfigLine by line
- Next.js applies the compiler only to files that need it (those with JSX or hooks), to keep builds fast. Builds can still be a little slower than without it.
Common anti-patterns
Most slow Next.js apps are slow for the same handful of reasons. Each one below comes with its fix.
"use client"at the top of a page or layout. Everything below becomes client JavaScript. Fix: move the directive down to the interactive leaves.- Client-side fetching of server-renderable data. A
useEffectfetch means a spinner and a second round trip. Fix: fetch in a Server Component. - Sequential awaits for independent data. Fix:
Promise.all, or separate Suspense boundaries. - Accidentally dynamic pages. One
cookies()call in a shared layout makes every page under it dynamic. Fix: read request data only where needed, inside Suspense. - Images without
sizes, orfillwithout a sized parent. Phones download desktop images and layouts jump. Fix: always setsizes. - Preloading or eager-loading everything. When everything is high priority, nothing is. Fix: preload only the LCP image; leave the rest lazy.
- Lazy-loading the first screen. A dynamically imported hero arrives later than a normal one. Fix: lazy-load only below the fold or behind an interaction.
- Big dependencies for small jobs. A full date library to print one date. Fix:
Intl, or a direct import from a smaller package. - Uncontrolled third-party scripts. Fix:
next/scriptwithlazyOnload, and remove what is no longer used. - Downloads racing the critical path. Images and prefetches starting alongside the code the first screen waits on. Fix: start them later, and check the waterfall on a throttled phone.
- Heavy Proxy logic, or no matcher. Fix: a narrow
matcherand only cheap checks. - Measuring
next dev. Development mode is unoptimized. Fix: measure a production build.
Before / After: fetching on the client
The first anti-pattern deserves a side-by-side, because it is the most common one in apps that moved from the Pages Router or from a single-page app:
// app/orders/page.tsx (before)'use client' import { useEffect, useState } from 'react' type Order = { id: string; total: number } export default function OrdersPage() { const [orders, setOrders] = useState<Order[] | null>(null) useEffect(() => { fetch('/api/orders') .then((r) => r.json()) .then(setOrders) }, []) if (!orders) return <p>Loading...</p> return <ul>{orders.map((o) => <li key={o.id}>{o.total}</li>)}</ul>}Line by line
- The request cannot start until the page's JavaScript has downloaded, run and hydrated. Then it goes from the browser to your API, and from your API to the database.
- The server-rendered HTML contains only "Loading...". The real content, and so the LCP, arrives after a whole extra round trip, and search engines may never see it.
// app/orders/page.tsx (after)import { getOrders } from '@/lib/orders' export default async function OrdersPage() { const orders = await getOrders() return <ul>{orders.map((o) => <li key={o.id}>{o.total}</li>)}</ul>}Line by line
- The query runs on the server during the render, next to the database. No API route is needed just to feed this page.
- The orders are in the first HTML. The page ships no JavaScript of its own, and there is no loading state because there is nothing to wait for in the browser.
A worked example from this site
Techniques are easier to trust with a real case. This portfolio felt slow on phones on its first visit. PageSpeed was not alarming: mobile Total Blocking Time was low. So instead of guessing, the page was recorded the way a phone loads it: a 390 px viewport, Slow 4G and a 4× CPU slowdown, with a cold cache.
The network waterfall showed the problem at once. The opening animation needs its animation library before it can move, and that library was deliberately loaded right after the first paint. But at the very same moment, the first paint also set off a photograph for the intro, the project screenshots for a gallery further down, and a prefetch of the journal page with its fonts, triggered by a closed menu. All of it shared one slow connection, so the animation library arrived seconds late, and the screen stayed dark.
The fixes were three small changes, each in its own commit and each measured:
- The intro photo and its project thumbnails start downloading after the animation library has arrived, not at first paint.
- The gallery's screenshots wait until the intro is over.
loading="lazy"alone had not held them back: on a phone the gallery sits inside the browser's lazy-load distance. - The closed menu's journal link prefetches only once the menu is opened (
prefetch={open ? null : false}).
The general pattern is "let the critical download finish first, then start the rest", which fits in a few lines:
// lib/after-critical.tslet critical: Promise<unknown> = Promise.resolve() /** Registers work the first screen waits on, such as a dynamic import. */export function markCritical(work: Promise<unknown>) { critical = Promise.all([critical, work.catch(() => undefined)])} /** Runs `start` once every critical download has settled. Returns a cancel. */export function afterCritical(start: () => void) { let live = true critical.then(() => live && start()) return () => { live = false }}Line by line
- Failures are swallowed on purpose: a broken critical download should not stop the rest of the page from loading.
- A component calls this in an effect to set an image's
src, or to switch a prefetch on, only when the critical work is done. - The cancel lets an effect clean up if the component unmounts first, so nothing starts for a component that is gone.
Measured on that emulated phone over five interleaved runs (old and new builds alternating, to cancel out noise), the animation started moving after 3.7 s instead of 7.2 s, and the intro finished at 10.7 s instead of 14.1 s. These are this site's numbers on one test setup, not a promise for yours. The lesson that carries over: look at what downloads at the same time as your critical path, because the fastest byte is the one that waits its turn.
A fourth change from the same round is worth copying too. The site's single global stylesheet still carried the styles for the chat widget, the contact dialog and the sound control, all of which load later. Moving those styles into files imported by the components themselves took them out of the render-blocking stylesheet on every page, with no visual change.
Performance checklist
Before you ship:
- Measured a production build with mobile throttling, plus field data (PageSpeed Insights or
useReportWebVitals). - Checked the bundle analyzer for the largest first-load modules.
"use client"only on interactive leaves.- Heavy or on-demand components behind
next/dynamic, called from Client Components; on-demand libraries loaded withimport()on intent. - Interactive shells receive server-rendered content as
children; server-only modules importserver-only. - Expensive renders use
useDeferredValueor transitions; long loops yield to the browser. - No oversized barrel imports;
optimizePackageImports(experimental) only where needed. - Every route static, ISR or streamed on purpose; no layout makes pages dynamic by accident; content edits trigger on-demand revalidation.
- Independent requests in parallel; repeated queries wrapped in
cache(). - Fetch caching explicit, and never shared for per-user data.
- Every responsive
<Image>hassizes; only the LCP image haspreload. - Fonts through
next/font, few families and weights. - Third-party scripts through
next/scriptwith the latest workable strategy. - No unexpected prefetches; long lists use
prefetch={false}. proxy.tsnarrow and light; nothing left on the deprecated Edge runtime.- Long cache headers for unchanging
public/files. - Checked the network waterfall on a throttled phone.
Conclusion
Next.js gives you fast defaults: Server Components, automatic code splitting, an image and font pipeline, prefetching and a CDN-friendly cache. Most performance work is about not fighting those defaults, and then about the small number of decisions only you can make: what has to be interactive, what can be cached and for how long, which image is the hero, and which downloads the first screen really waits on.
Work in a loop. Measure a production build on a throttled phone, find the biggest problem, fix that one thing, and measure again. Keep the changes that make a difference and undo the ones that do not. No list of techniques, including this one, knows your app as well as your own measurements do.
မြန်တဲ့ website ဆိုတာ အပိုအလှဆင် feature မဟုတ်ပါဘူး။ နှေးတယ်လို့ ခံစားရတဲ့ page ကို လူတွေ ထွက်သွားကြပြီး search engine တွေကလည်း page experience ကို ranking signal အဖြစ် သုံးပါတယ်။ Next.js က အလုပ်အများကြီးကို ကိုယ့်အစား လုပ်ပေးထားပြီးသားပါ၊ ဒါပေမဲ့ သတိမထားဘဲ import တစ်ကြောင်း ဒါမှမဟုတ် sizes မပါတဲ့ image တစ်ပုံနဲ့တင် အဲဒီအလုပ်တွေ အလကား ဖြစ်သွားနိုင်ပါတယ်။
ဒီ entry က Next.js app တွေကို ပိုပေါ့ပါးပြီး ပိုမြန်အောင် လုပ်ပေးတဲ့ နည်းလမ်းတွေကို App Router နဲ့ Next.js 16 အတွက် ရေးထားတဲ့ လက်တွေ့ ခရီးစဉ်ပါ။ နည်းလမ်းတစ်ခုချင်းစီအတွက် ဘာလဲ၊ ဘာကြောင့် အထောက်အကူ ဖြစ်လဲ၊ code ဥပမာနဲ့ ဘယ်အချိန် မသုံးသင့် လဲ ဆိုတာ ပါပါတယ် — performance အကြံဉာဏ် အများစုက နေရာတိုင်းမှာ လျှောက်သုံးရင် အန္တရာယ် ဖြစ်လာတတ်လို့ပါ။ Code block တိုင်းမှာ စာကြောင်းတွေအတွက် မှတ်စု ပါပါတယ်၊ စာကြောင်းတစ်ကြောင်းကို နှိပ်ပြီး (ဒါမှမဟုတ် hover လုပ်ပြီး) ဖတ်ကြည့်ပါ။
Performance ဘာကြောင့် အရေးကြီးလဲ၊ ဘာကို တိုင်းမလဲ
Google က page တစ်ခုရဲ့ user experience ကို Core Web Vitals သုံးခုနဲ့ အနှစ်ချုပ်ပါတယ်။ တစ်ခုချင်းစီမှာ "good" သတ်မှတ်ချက် ရှိပြီး တကယ့် visit တွေရဲ့ 75th percentile မှာ တိုင်းပါတယ်—
- LCP (Largest Contentful Paint): ပထမ screen ပေါ်က အကြီးဆုံး image ဒါမှမဟုတ် စာသားအကွက် paint ဖြစ်ဖို့ ကြာချိန်။ 2.5 s သို့မဟုတ် အောက်ဆို good ပါ။ အဓိကအားဖြင့် network နဲ့ ဆိုင်ပါတယ် — HTML၊ render-blocking CSS၊ font နဲ့ hero image။
- INP (Interaction to Next Paint): tap၊ click ဒါမှမဟုတ် key နှိပ်ပြီးနောက် page က မျက်စိနဲ့မြင်ရအောင် တုံ့ပြန်ဖို့ ကြာချိန်၊ visit တစ်ခုလုံးမှာ ယူတိုင်းပါတယ်။ 200 ms သို့မဟုတ် အောက်ဆို good ပါ။ အဓိကအားဖြင့် main thread ပေါ်က JavaScript နဲ့ ဆိုင်ပါတယ်။ INP က 2024 မှာ FID ကို Core Web Vital အဖြစ် အစားထိုးခဲ့ပါတယ်။
- CLS (Cumulative Layout Shift): load ဖြစ်နေစဉ် content တွေ ဘယ်လောက် ခုန်ရွှေ့သွားလဲ။ 0.1 သို့မဟုတ် အောက်ဆို good ပါ။ အဓိကအားဖြင့် အရွယ်အစား မသတ်မှတ်ထားတဲ့ image တွေ၊ နောက်ကျမှ ရောက်လာတဲ့ font တွေနဲ့ ဖတ်နေတဲ့ နေရာအပေါ်မှာ ထိုးထည့်လိုက်တဲ့ content တွေကြောင့်ပါ။
TTFB (time to first byte) နဲ့ FCP (first contentful paint) ကိုလည်း စောင့်ကြည့်ပါ။ TTFB နှေးနေရင် ဘယ် front-end လှည့်ကွက်ကမှ LCP ကို ကယ်လို့ မရပါဘူး။
အရင် တိုင်းတာပါ
မတိုင်းတာဘဲ optimize လုပ်တာ ခန့်မှန်းတာပါပဲ။ Lab data က simulate လုပ်ထားတဲ့ device တစ်ခုပေါ်က test ကနေ လာပါတယ် — ထပ်ခါထပ်ခါ တူညီစွာ run နိုင်ပြီး မပြင်ခင်နဲ့ ပြင်ပြီး နှိုင်းယှဉ်ဖို့ ကောင်းပါတယ်။ Field data က တကယ့်ဖုန်းတွေပေါ်က တကယ့် visitor တွေဆီကနေ လာပြီး Google က ranking အတွက် သုံးတာ ဒါပါ။ နှစ်မျိုးလုံး လိုပါတယ်။
- Lighthouse (Chrome DevTools ထဲမှာ) က simulated throttling နဲ့ lab score ပေးပါတယ်။ Production build (
next build && next start) ပေါ်မှာ run ပါ၊ တမင် optimize မလုပ်ထားတဲ့next devပေါ်မှာ ဘယ်တော့မှ မ run ပါနဲ့။ - PageSpeed Insights က Lighthouse lab data အပြင် site မှာ traffic လုံလောက်ရင် Chrome UX Report ကနေ real-user field data ကိုပါ ပြပါတယ်။
- DevTools Performance panel က long task၊ layout၊ paint နဲ့ network ကို frame တစ်ခုချင်း ပြပါတယ်။ အလယ်အလတ်တန်းစား ဖုန်းတစ်လုံး မြင်ရတာကို မြင်ဖို့ CPU နဲ့ network throttling ကို ဖွင့်ပါ။
useReportWebVitalsက visit တိုင်းကနေ ကိုယ်ပိုင် field data ကို ကိုယ်လိုချင်တဲ့ နေရာကို ပို့ပေးပါတယ်။- Bundle analyzer က ကိုယ့် JavaScript ထဲမှာ ဘာတွေ ပါလဲ ပြပါတယ်။
useReportWebVitals နဲ့ real-user metric တွေ စုဆောင်းခြင်း
ဘာလဲ: next/web-vitals က hook တစ်ခုဖြစ်ပြီး browser က metric တစ်ခုချင်း (LCP၊ INP၊ CLS၊ FCP၊ TTFB) ကို report လုပ်တိုင်း ကိုယ့် function ကို ခေါ်ပေးပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: lab test တွေက device တစ်လုံးပေါ်မှာပဲ run တာပါ၊ ဒါကတော့ တကယ့် visitor တွေ ဘာခံစားရလဲကို page တစ်ခုချင်း ပြောပြပါတယ်။
// app/_components/web-vitals.tsx'use client' import { useReportWebVitals } from 'next/web-vitals' type Metric = Parameters<Parameters<typeof useReportWebVitals>[0]>[0] // Defined outside the component, so the function never changes between renders.function report(metric: Metric) { const body = JSON.stringify({ name: metric.name, // 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB' | ... value: metric.value, rating: metric.rating, // 'good' | 'needs-improvement' | 'poor' id: metric.id, page: location.pathname, }) // sendBeacon survives the page being closed; fetch with keepalive is the fallback. if (!navigator.sendBeacon?.('/api/vitals', body)) { fetch('/api/vitals', { method: 'POST', body, keepalive: true }) }} export function WebVitals() { useReportWebVitals(report) return null}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Hook က browser ထဲမှာ run တာမို့ ဒီ component လေးက Client Component ဖြစ်ပါတယ်။ ဘာမှ render မလုပ်လို့ JavaScript ကုန်ကျစရိတ် မရှိသလောက်ပါပဲ။
- Metric type ကို hook ရဲ့ signature ကနေ တိုက်ရိုက် ဖတ်ယူထားလို့ Next.js က update လုပ်ရင်လည်း မှန်နေဆဲပါ။
- Docs က callback ကို မပြောင်းလဲဖို့ တောင်းဆိုထားပါတယ် — render တိုင်း function အသစ်ဆိုရင် အဲဒီအချိန်အထိ စုထားတဲ့ metric တွေကို ထပ် report လုပ်ပြီး နှစ်ခါ ရေတွက်မိသွားပါလိမ့်မယ်။
- CLS နဲ့ INP လို metric တွေက page ကို ဖျောက်လိုက်မှ အပြီးသတ် ဖြစ်ပါတယ်။
navigator.sendBeaconက အဲဒီအခိုက်အတန့်အတွက် အတိအကျ တည်ဆောက်ထားတာဖြစ်ပြီး tab ပိတ်လိုက်လည်း မပယ်ဖျက်ပါဘူး။ <WebVitals />ကို root layout ထဲမှာ တစ်ခါပဲ ထည့်ပါ။ Layout ကိုယ်တိုင်ကတော့ Server Component အဖြစ် ဆက်ရှိနေပါတယ်။
တစ်ဖက်အစွန်းမှာတော့ metric တစ်ခုချင်းကို လက်ခံတဲ့ Route Handler လေးတစ်ခု ရှိပါတယ်။ အစမှာ log ထုတ်ကြည့်ပြီး နောက်ပိုင်း Google လုပ်သလိုပဲ page တစ်ခုချင်းအလိုက် 75th percentile နဲ့ စုစည်းပါ။
// app/api/vitals/route.tsimport { NextResponse, type NextRequest } from 'next/server' type Vital = { name: string; value: number; rating: string; id: string; page: string }const NAMES = new Set(['LCP', 'INP', 'CLS', 'FCP', 'TTFB']) export async function POST(request: NextRequest) { const vital = (await request.json()) as Vital if (!NAMES.has(vital.name) || typeof vital.value !== 'number') { return new NextResponse(null, { status: 400 }) } console.log(JSON.stringify({ ...vital, at: Date.now() })) return new NextResponse(null, { status: 204 })}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
sendBeaconက string ကို request body အဖြစ် ပို့ပြီး content type ဘာပဲပြောပြောrequest.json()က parse လုပ်ပေးပါတယ်။- ဒီ endpoint က public ဖြစ်လို့ ဘယ်သူမဆို post လုပ်နိုင်ပါတယ်။ ဘာမှ မသိမ်းခင် မျှော်လင့်ထားတဲ့ metric နာမည်တွေနဲ့ value အတွက် number ကိုပဲ လက်ခံပါ။
- Metric တစ်ခုကို JSON စာကြောင်းတစ်ကြောင်းဆိုရင် host ရဲ့ log တွေထဲမှာ ရှာရလွယ်ပါတယ်။ ပမာဏ များလာရင် database ဒါမှမဟုတ် analytics store ထဲ ရေးပြီး လိုတာလောက်ပဲ ထားပါ — IP address မထား၊ cookie မထား။
204 No Content: browser က အဖြေ မလိုလို့ ဘာမှ ပြန်မပို့ပါဘူး။
DevTools Performance panel ကို ဖတ်ခြင်း
Lighthouse က page နှေးတယ် ဆိုတာ ပြောပြပြီး Performance panel က ဘာကြောင့် လဲ ပြောပြပါတယ်။ ပြဿနာအများစုကို ရှာတွေ့စေမယ့် လုပ်ထုံးလုပ်နည်း တိုတိုလေး—
- Production site ကို ဖွင့်ပြီး DevTools → Performance ကို သွားပါ။ Laptop ကို အလယ်အလတ်တန်းစား ဖုန်းလို ပြုမူစေဖို့ CPU ကို 4× ဒါမှမဟုတ် 6× slowdown၊ Network ကို "Slow 4G" ထားပါ။
- Reload-and-record ခလုတ်ကို နှိပ်ပါ။ Page load ကို record လုပ်ရင် Network track က waterfall အဖြစ် ပြပါတယ် — ပထမ screen လိုအပ်တဲ့ HTML၊ CSS၊ font နဲ့ hero image နဲ့ တစ်ပြိုင်နက် download ဖြစ်နေတဲ့ အရာ မှန်သမျှကို ရှာပါ။ အဲဒါတွေ တစ်ခုချင်းက LCP နဲ့ ပြိုင်နေပါတယ်။
- Main track ပေါ်မှာ ထောင့်နီပါတဲ့ task တွေက long task (50 ms ကျော်) တွေပါ။ တစ်ခုကို click ပြီး Bottom-Up ကို ဖွင့်ရင် ဘယ် function တွေက အချိန်ယူလဲ မြင်ရပါတယ်။ Load ဖြစ်နေစဉ် long task တွေက interactivity ကို နှောင့်နှေးစေပြီး click ပြီးနောက် long task တွေကတော့ INP တိုင်းတာတဲ့ အရာပါ။
- INP အတွက် interact လုပ်နေစဉ် (search box ထဲ ရိုက်တာ၊ menu ဖွင့်တာ) record လုပ်ပြီး Interactions track ကို ကြည့်ပါ။ Interaction တစ်ခုချင်းကို input delay၊ processing time နဲ့ presentation delay လို့ ခွဲပြလို့ handler ကို သက်သာအောင် လုပ်ရမလား၊ အလုပ်ကို လမ်းကြောင်းပေါ်က ဖယ်ရမလား သိရပါတယ်။
- Timings track ထဲက LCP marker ကို ရှာပြီး အဲဒီမတိုင်ခင် ဘာတွေ load ဖြစ်နေဆဲ၊ run နေဆဲလဲ စစ်ပါ။
Bundle ထဲမှာ ဘာတွေ ပါလဲ ကြည့်ခြင်း
ဘာလဲ: client နဲ့ server bundle ထဲက module တိုင်းရဲ့ treemap ပါ။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: အကြီးဆုံး အကျိုးကျေးဇူးတွေက များသောအားဖြင့် မထင်မှတ်လောက်အောင် ကြီးနေတဲ့ dependency တစ်ခုကနေ လာပြီး ဒီနည်းမှလွဲရင် မမြင်ရပါဘူး။ Webpack build တွေမှာ @next/bundle-analyzer ကို သုံးပါ။ Turbopack (Next.js 16 ရဲ့ default bundler) မှာတော့ built-in analyzer ဖြစ်တဲ့ next experimental-analyze ရှိပြီး 16.1 ကစပြီး ရနိုင်ကာ experimental အဆင့်မှာပဲ ရှိပါသေးတယ်။
// next.config.tsimport type { NextConfig } from 'next'import bundleAnalyzer from '@next/bundle-analyzer' const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true',}) const nextConfig: NextConfig = {} export default withBundleAnalyzer(nextConfig) // Webpack: ANALYZE=true next build --webpack// Turbopack: next experimental-analyzeတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Default အနေနဲ့ ပိတ်ထားလို့ ပုံမှန် build တွေ မနှေးပါဘူး။ Report လိုချင်မှပဲ ဖွင့်ပါ။
@next/bundle-analyzerက webpack plugin ဖြစ်လို့ report ထွက်ဖို့ build ကို--webpackနဲ့ run ရပါတယ်။- Turbopack analyzer က module တွေကို filter လုပ်ပြီး တစ်ခုချင်း ဘာကြောင့် import ဖြစ်လဲ ခြေရာခံနိုင်တဲ့ interactive view ကို ဖွင့်ပေးပါတယ်။ နောက်မှ နှိုင်းယှဉ်ဖို့
--outputထည့်ပြီး ရလဒ်ကို.next/diagnostics/analyzeထဲ ရေးထားနိုင်ပါတယ်။
JavaScript နည်းနည်းပဲ ပို့ပါ
JavaScript kilobyte တိုင်းကို page interactive မဖြစ်ခင် download၊ parse နဲ့ run လုပ်ရပြီး အလယ်အလတ်တန်းစား ဖုန်းမှာ run တဲ့အပိုင်းက အနှေးဆုံး ဖြစ်တတ်ပါတယ်။ JavaScript နည်းလေ LCP (bandwidth အပြိုင်အဆိုင် နည်းလေ) နဲ့ INP (main-thread အလုပ် နည်းလေ) နှစ်ခုလုံး တစ်ပြိုင်နက် ကောင်းလာလေပါ။
Server Component ကို default ထား၊ "use client" ကို အရွက်တွေမှာပဲ ထား
ဘာလဲ: App Router မှာ file တစ်ခုက "use client" နဲ့ မစရင် component တိုင်းက Server Component ပါ။ Server Component တွေက server ပေါ်မှာ render လုပ်ပြီး HTML နဲ့ ရလဒ်ရဲ့ ကျစ်လျစ်တဲ့ ဖော်ပြချက်ကို ပို့ပါတယ်၊ သူတို့ code က browser ဆီ ဘယ်တော့မှ မရောက်ပါဘူး။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: "use client" က နယ်နိမိတ်တစ်ခု သတ်မှတ်ပြီး အဲဒီနယ်နိမိတ်အောက်မှာ import လုပ်တာ အားလုံး client JavaScript ဖြစ်သွားပါတယ်။ Page ထိပ်မှာ ထားရင် page တစ်ခုလုံး browser ဆီ ရောက်သွားပါတယ်၊ state လိုတဲ့ button တစ်ခုပေါ်မှာပဲ ထားရင် အဲဒီ button ပဲ ရောက်ပါတယ်။
// app/products/[id]/product-view.tsx (before)'use client' import { useState } from 'react'import { marked } from 'marked' type Product = { id: string; name: string; description: string } export function ProductView({ product }: { product: Product }) { const [liked, setLiked] = useState(false) return ( <article> <h1>{product.name}</h1> <div dangerouslySetInnerHTML={{ __html: marked.parse(product.description) as string }} /> <button onClick={() => setLiked(!liked)}>{liked ? 'Liked' : 'Like'}</button> </article> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- View တစ်ခုလုံးက Client Component ဖြစ်နေလို့ သူ render လုပ်တာ၊ import လုပ်တာ အားလုံးကို visitor တိုင်း download လုပ်ရပါတယ်။
- Description တစ်ခုကို HTML အဖြစ် တစ်ခါ ပြောင်းဖို့အတွက်နဲ့ Markdown parser တစ်ခုလုံး browser ဆီ ရောက်သွားပါတယ်။ Server ပေါ်မှာ run လို့ ရခဲ့ပါတယ်။
- Page ရဲ့ interactive ဖြစ်တဲ့ တစ်ခုတည်းသော အပိုင်းက state တစ်ခုပါတဲ့ button တစ်ခုပါပဲ။
// app/products/[id]/product-view.tsx (after)import { marked } from 'marked'import { LikeButton } from './like-button' type Product = { id: string; name: string; description: string } export async function ProductView({ product }: { product: Product }) { return ( <article> <h1>{product.name}</h1> <div dangerouslySetInnerHTML={{ __html: await marked.parse(product.description) }} /> <LikeButton productId={product.id} /> </article> )} // app/products/[id]/like-button.tsx// 'use client'// export function LikeButton({ productId }: { productId: string }) { ... }တစ်ကြောင်းချင်း ရှင်းလင်းချက်
"use client"မရှိတော့ပါဘူး — view က Server Component ဖြစ်သွားပါပြီ။ Markdown parser က server ပေါ်မှာ run ပြီး browser ဆီ ဘယ်တော့မှ မပို့ပါဘူး။- Server Component က
asyncဖြစ်နိုင်ပြီး data ဒါမှမဟုတ် အလုပ်ကို တိုက်ရိုက် await လုပ်နိုင်ပါတယ်။ သူ့ကို render လုပ်တဲ့ page ကလည်း product ကို database နဲ့ နီးတဲ့ server ပေါ်မှာ fetch လုပ်နိုင်ပြီး spinner မလို၊ browser ကနေ API ကို အသွားအပြန် မလိုပါဘူး။ LikeButtonတစ်ခုတည်းပဲ Client Component ပါ။ သူ့ props တွေက server ကနေ browser ကို ခရီးသွားရလို့ serializable (string၊ number၊ plain object) ဖြစ်ရပါမယ်။"use client"စာကြောင်းကlike-button.tsxထိပ်မှာ ရှိပါတယ်။ ဒီ file သေးသေးလေးနဲ့ သူ import လုပ်တာတွေပဲ client JavaScript ဖြစ်ပါတယ်။
Server Component တွေကို Client Component ထဲ children အဖြစ် ထည့်ပေးပါ
ဘာလဲ: Client Component က Server Component ကို import လုပ်လို့ မရပေမဲ့ children (ဒါမှမဟုတ် တခြား prop) အဖြစ် လက်ခံ လို့ ရပါတယ်။ Server က အဲဒီ content ကို render လုပ်ပြီး ပြီးစီးသွားတဲ့ ရလဒ်ကို Client Component ကို ပေးပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: interactive shell (tab၊ accordion၊ modal၊ carousel) တစ်ခုက သူ့ထဲက content တွေကို client bundle ထဲ ဆွဲမသွင်းတော့ပါဘူး။ Shell ရဲ့ ကိုယ်ပိုင် code ပဲ ရောက်ပြီး content က server-rendered HTML အဖြစ် ဆက်ရှိနေပါတယ်။
// app/products/page.tsximport { Tabs } from './tabs'import { getReviews, getSpecs } from '@/lib/catalog' export default async function ProductPage() { const [specs, reviews] = await Promise.all([getSpecs(), getReviews()]) return ( <Tabs labels={['Specs', 'Reviews']}> <ul>{specs.map((s) => <li key={s}>{s}</li>)}</ul> <ul>{reviews.map((r) => <li key={r}>{r}</li>)}</ul> </Tabs> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Data ကို page က server ပေါ်မှာ parallel fetch လုပ်ပါတယ်။
Tabsက Client Component ပါ — ဖွင့်ထားတဲ့ tab ကို မှတ်ထားဖို့ state လိုပါတယ်။- ဒီ list တွေကို Server Component ထဲမှာ ရေးပြီး children အဖြစ် ပေးပို့ပါတယ်။ Render ပြီးသား ရလဒ်အဖြစ် ရောက်လာတာမို့ သူတို့ကို တည်ဆောက်ခဲ့တဲ့ code နဲ့ သုံးခဲ့တဲ့ library မှန်သမျှ browser ဆီ ဘယ်တော့မှ မရောက်ပါဘူး။
// app/products/tabs.tsx'use client' import { Children, useState, type ReactNode } from 'react' export function Tabs({ labels, children }: { labels: string[]; children: ReactNode }) { const [active, setActive] = useState(0) const panels = Children.toArray(children) return ( <div> <div role="tablist"> {labels.map((label, i) => ( <button key={label} role="tab" aria-selected={i === active} onClick={() => setActive(i)}> {label} </button> ))} </div> <div role="tabpanel">{panels[active]}</div> </div> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Children တွေက render ပြီးသား content ပါ။ Client Component က သူတို့ကို စီစဉ်၊ ပြ ဒါမှမဟုတ် ဖျောက်နိုင်ပေမဲ့ ဘယ်လို ဖန်တီးထားလဲ မသိသလို ဂရုလည်း မစိုက်ပါဘူး။
- Panel တွေ ဘယ်လောက် ကြွယ်ဝပါစေ tab logic — state နဲ့ button စာကြောင်း အနည်းငယ် — ပဲ client JavaScript ဖြစ်ပါတယ်။
server-only နဲ့ server code ကို bundle ထဲ မဝင်အောင် ထားပါ
ဘာလဲ: server-only package (npm install server-only နဲ့ install လုပ်ပါ) က module တစ်ခုကို client code ထဲ import လုပ်မိတာနဲ့ build ကို fail ဖြစ်စေပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: secret ကို ဖတ်ပြီး ပိုက်ဆံပေးရတဲ့ API ကို ခေါ်တဲ့ helper တစ်ခုက အပြစ်ကင်းတဲ့ import တစ်ခုကနေ Client Component ထဲ ရောက်သွားနိုင်ပါတယ်။ အကောင်းဆုံးဆိုရင် bundle ကို လေးစေပြီး အဆိုးဆုံးဆိုရင် secret ရဲ့ နာမည်နဲ့ API ပုံစံကို ပေါက်ကြားစေတာ၊ ဒါမှမဟုတ် browser ထဲမှာ variable က undefined ဖြစ်လို့ runtime မှာ ပျက်တာ ဖြစ်တတ်ပါတယ်။
// lib/billing.tsimport 'server-only' const API_KEY = process.env.BILLING_API_KEY export async function getInvoices(customerId: string) { const res = await fetch(`https://billing.example.com/invoices?customer=${customerId}`, { headers: { Authorization: `Bearer ${API_KEY}` }, }) if (!res.ok) throw new Error('Could not load invoices') return (await res.json()) as { id: string; total: number }[]}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- ထိပ်မှာ စာကြောင်းတစ်ကြောင်းပဲ။ Client Component တစ်ခုခုက ဒီ file ကို တိုက်ရိုက်ဖြစ်ဖြစ်၊ တခြား module တွေကနေ ဖြစ်ဖြစ် import လုပ်ရင် build က import ကို နာမည်တပ်ပြတဲ့ error နဲ့ ရပ်သွားပါတယ်။
NEXT_PUBLIC_prefix မပါတဲ့ environment variable တွေကို server ပေါ်မှာပဲ ရနိုင်ပြီး browser ထဲမှာundefinedဖြစ်ပါလိမ့်မယ်။server-onlyက အဲဒီ တိတ်ဆိတ်တဲ့ bug ကို build error အဖြစ် ပြောင်းပေးပါတယ်။
လေးလံတဲ့ Client Component တွေကို next/dynamic နဲ့ lazy-load လုပ်ပါ
ဘာလဲ: next/dynamic က component တစ်ခုရဲ့ code ကို သီးခြား chunk တစ်ခုထဲမှာ render ဖြစ်မှပဲ load လုပ်ပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: chart library၊ code editor ဒါမှမဟုတ် video player တစ်ခုက visitor အများစု ဘယ်တော့မှ မသုံးတဲ့ JavaScript kilobyte ဆယ်ချီ ဖြစ်နိုင်ပါတယ်။ သီးခြားခွဲထုတ်လိုက်ရင် first load ထဲက ဖယ်ထားနိုင်ပါတယ်။
// app/dashboard/sales-panel.tsx'use client' import dynamic from 'next/dynamic'import { useState } from 'react' const SalesChart = dynamic(() => import('./sales-chart'), { ssr: false, loading: () => <div className="h-72 animate-pulse rounded-lg bg-neutral-800" />,}) export function SalesPanel() { const [open, setOpen] = useState(false) return ( <section> <button onClick={() => setOpen(true)}>Show sales chart</button> {open && <SalesChart />} </section> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Chart နဲ့ သူ့ library က သီးခြား chunk ထဲ ရောက်သွားပါတယ်။ Page ရဲ့ first load က သူတို့ကို မသယ်တော့ပါဘူး။
ssr: falseက browser ထဲမှာပဲ အဓိပ္ပာယ်ရှိတဲ့ component တွေ (windowကို ဖတ်တာ၊ DOM ကို တိုင်းတာ၊ canvas ပေါ် ဆွဲတာ) အတွက် server rendering ကို ကျော်ပါတယ်။ Client Component ထဲမှာပဲ ခွင့်ပြုပြီး Server Component ထဲမှာဆိုရင် error ပါ။- Chart နဲ့ အမြင့်တူတဲ့ placeholder မို့ chunk ရောက်လာတဲ့အခါ ဘာမှ မခုန်ပါဘူး။ ဒါက CLS ကို သုည ထိန်းထားပေးပါတယ်။
- Chart ကို ပထမဆုံး render လုပ်တဲ့အချိန် — ဒီမှာတော့ click ပြီးနောက် — မှာ chunk ကို တောင်းပါတယ်။ Scroll လုပ်လို့ မြင်ကွင်းထဲ ရောက်တဲ့အခါ ဒါမှမဟုတ် browser အားနေတဲ့အခါလည်း ဖြစ်နိုင်ပါတယ်။
Docs မှာ သိထားသင့်တဲ့ ကန့်သတ်ချက်တစ်ခု ရှိပါတယ် — Server Component က Client Component ကို dynamic import လုပ်တဲ့အခါ automatic code splitting ကို လောလောဆယ် မထောက်ပံ့သေးပါဘူး။ အပေါ်က ဥပမာလို dynamic() ခေါ်တာတွေကို Client Component ထဲမှာ လုပ်ပါ။
Library ကို လိုအပ်တဲ့ အခိုက်အတန့်မှာ import လုပ်ပါ
ဘာလဲ: next/dynamic က component တွေအတွက်ပါ။ ရိုးရိုး library (search index၊ PDF exporter၊ Markdown renderer၊ date picker ရဲ့ locale data) တွေအတွက်တော့ event handler ထဲက dynamic import() က အလုပ်တူတူ လုပ်ပေးပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: feature ကို တကယ်သုံးတဲ့ visitor တွေပဲ library ကို fetch လုပ်ပြီး search box ကို focus လုပ်တာလို ရည်ရွယ်ချက် ပြတဲ့အခါမှပဲ fetch လုပ်ပါတယ်။
// app/components/search-box.tsx'use client' import { useRef, useState } from 'react' type Search = (query: string) => string[] export function SearchBox() { const searchRef = useRef<Promise<Search> | null>(null) const [results, setResults] = useState<string[]>([]) function load() { searchRef.current ??= import('./fuzzy-search').then((m) => m.createSearch()) return searchRef.current } async function onChange(query: string) { const search = await load() setResults(search(query)) } return ( <div> <input type="search" placeholder="Search posts" onFocus={() => void load()} onChange={(e) => onChange(e.target.value)} /> <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul> </div> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Function တစ်ခုတည်းက download ကို စတင်ပြီး ပြန်ပေးလို့ ခေါ်သူတိုင်း promise တစ်ခုတည်းကို စောင့်ပါတယ်။
??=က ref အလွတ်ဖြစ်နေမှပဲ assign လုပ်ပါတယ် — focus နဲ့ typing ကload()ကို ဘယ်နှခါခေါ်ခေါ် module ကို တစ်ခါပဲ import လုပ်ပါတယ်။ Bundler က./fuzzy-searchနဲ့ သူ import လုပ်တာ အားလုံးကို သီးခြား chunk ထဲ ထည့်ပါတယ်။- Chunk မရောက်ခင် တစ်ယောက်ယောက် ရိုက်လိုက်ရင် ပထမ search က သူ့ကို စောင့်ရုံပါပဲ၊ ဘာမှ မဆုံးရှုံးပါဘူး။
- Focus က ရည်ရွယ်ချက်ရဲ့ signal ပါ — visitor က keyboard ကို လှမ်းနေတုန်း download စတင်လို့ ပထမ keystroke အချိန်မှာ အဆင်သင့် ဖြစ်နေတတ်ပါတယ်။
Barrel-file import တွေကို ရှောင်ပြီး optimizePackageImports ကို သိထားပါ
ဘာလဲ: barrel file ဆိုတာ folder တစ်ခုထဲက အရာအားလုံးကို ပြန် export လုပ်တဲ့ index.ts ပါ။ Barrel ကနေ function တစ်ခု import လုပ်တာနဲ့ folder တစ်ခုလုံးကို bundle ထဲ ဆွဲသွင်းနိုင်ပါတယ်၊ အနည်းဆုံးတော့ bundler ကို အဲဒီထဲက file တိုင်းကို process လုပ်ခိုင်းပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: သတ်မှတ်ထားတဲ့ file ကနေ တိုက်ရိုက် import လုပ်ရင် bundle က ကိုယ်သုံးတာလောက်ပဲ ရှိပြီး build လည်း ပိုမြန်ပါတယ်။
// app/components/price.tsx// Before: one name from a barrel that re-exports 40 helpers, some with heavy dependencies// import { formatPrice } from '@/utils' // After: straight from the file that defines itimport { formatPrice } from '@/utils/format-price' export function Price({ cents }: { cents: number }) { return <span>{formatPrice(cents)}</span>}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Helper ၄၀ လုံး ပါလာမလား ဆိုတာ သူတို့ ဘယ်လို ရေးထားလဲပေါ် မူတည်ပါတယ် — file ထိပ်မှာ side effect ပါတဲ့ helper တစ်ခုတည်းနဲ့တင် tree-shaking ကို အလုပ်မဖြစ်အောင် လုပ်နိုင်ပါတယ်။
- တိုက်ရိုက် import က bundler ကို လိုက်ရမယ့် file တစ်ခုတည်းပဲ ပေးပါတယ်။ တခြား file တွေ ဘာပဲလုပ်လုပ် အမြဲ မှန်ပါတယ်။
Export ရာနဲ့ချီ ပါတဲ့ third-party library တွေအတွက် Next.js မှာ experimental.optimizePackageImports ရှိပြီး ကိုယ်သုံးတဲ့ module တွေကိုပဲ load လုပ်အောင် import တွေကို ပြန်ရေးပေးပါတယ်။ ဒါက experimental ဖြစ်ပြီး လူသုံးများတဲ့ library အများအပြား (lucide-react၊ date-fns၊ lodash-es၊ @mui/material၊ @heroicons/react၊ recharts နဲ့ react-icons စတာတွေ) ကို default အနေနဲ့ optimize လုပ်ပြီးသားပါ။ Analyzer က trim မဖြစ်ဘူးလို့ ပြမှပဲ library ကို list ထဲ ထည့်ပါ။
// next.config.tsimport type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { optimizePackageImports: ['my-icon-library'], },} export default nextConfigတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Module အများကြီး export လုပ်ပြီး default list ထဲ မပါတဲ့ package တွေအတွက်ပဲ ဖြစ်ပါတယ်။ Experimental option ဖြစ်လို့ upgrade လုပ်တိုင်း ပြန်စစ်ပါ။
လေးလံတဲ့ library တွေကို ပေါ့ပါးတဲ့ဟာ ဒါမှမဟုတ် platform နဲ့ အစားထိုးပါ
ဘာလဲ: dependency တစ်ခုက သူ့အလေးချိန်နဲ့ ထိုက်တန်ရဲ့လား စစ်ဆေးတာပါ။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: အရင်က library တွေ install လုပ်ခဲ့ရတဲ့ အလုပ်အများစုကို browser က အခု ကိုယ်တိုင် လုပ်ပေးပါတယ်။ ရက်စွဲ၊ ဂဏန်း၊ ငွေကြေး၊ relative time နဲ့ list formatting တွေက Intl ထဲမှာ ပါပြီးသားပါ။ ရိုးရှင်းတဲ့ animation တွေက CSS ပါ။ HTTP client အများစုကို fetch က အစားထိုးပါတယ်။ Deep-clone က structuredClone ပါ။
// lib/format.ts (no date or number library needed)const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' })const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })const relative = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) export const formatDate = (d: Date) => dateFormat.format(d) // "22 Oct 2026"export const formatMoney = (cents: number) => money.format(cents / 100) // "$12.50" export function timeAgo(d: Date, now = Date.now()) { const days = Math.round((d.getTime() - now) / 86_400_000) return relative.format(days, 'day') // "yesterday", "in 3 days"}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Formatter တွေကို module level မှာ တစ်ခါပဲ ဖန်တီးပြီး ပြန်သုံးပါတယ်။
Intlformatter ဖန်တီးတာက ကုန်ကျစရိတ်များတဲ့ အပိုင်းပါ၊.format()ခေါ်တာကတော့ သက်သာပါတယ်။ numeric: 'auto'က "1 day ago" အစား "yesterday"၊ "tomorrow" လို သဘာဝကျတဲ့ စကားလုံးတွေ ပေးပါတယ်။
Interaction တွေကို မြန်အောင် ထားပါ (INP)
JavaScript နည်းနည်း ပို့တာက page ကို ပိုစောစော အဆင်သင့် ဖြစ်စေပါတယ်၊ INP ကတော့ အဲဒီ နောက်ပိုင်း — တစ်ယောက်ယောက် tap ဒါမှမဟုတ် ရိုက်တိုင်း — ဖြစ်တာအကြောင်းပါ။ Main thread အလုပ်များနေရင် browser က input ရဲ့ တုံ့ပြန်မှုကို paint မလုပ်နိုင်လို့ ရည်မှန်းချက်က ရိုးရှင်းပါတယ် — interaction တစ်ခုက စတင်တဲ့ အလုပ်တစ်ခုချင်းကို တိုတိုထားပြီး အရေးပေါ်အပိုင်း (input ထဲ စာလုံးပေါ်လာတာ၊ button နှိပ်ထားသလို ပုံပေါက်တာ) ကို အရင် paint ဖြစ်ပါစေ။
useDeferredValue နဲ့ typing ကို တုံ့ပြန်မှုရှိအောင် ထားပါ
ဘာလဲ: React ရဲ့ useDeferredValue က နောက်ကျကျန်ခွင့်ရှိတဲ့ တန်ဖိုးမိတ္တူတစ်ခု ပေးပါတယ်။ React က အရေးပေါ် update (input) ကို အရင် render လုပ်ပြီး ကုန်ကျစရိတ်များတဲ့ အပိုင်းကို တန်ဖိုးအသစ်နဲ့ နောက်ကွယ်မှာ ပြန် render လုပ်ကာ keystroke နောက်တစ်ခု ရောက်လာရင် အဲဒီအလုပ်ကို စွန့်လွှတ်ပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: keystroke တိုင်းမှာ list ရှည်ကြီးကို filter လုပ်တာက typing ကို ပိတ်ဆို့နိုင်ပါတယ်၊ deferred value နဲ့ဆိုရင် list က အမီလိုက်နေစဉ် input က ချောမွေ့နေဆဲပါ။
// app/components/product-filter.tsx'use client' import { memo, useDeferredValue, useState } from 'react' type Product = { id: string; name: string } export function ProductFilter({ products }: { products: Product[] }) { const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) const stale = query !== deferredQuery return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter products" /> <div style={{ opacity: stale ? 0.6 : 1 }}> <ResultList products={products} query={deferredQuery} /> </div> </div> )} const ResultList = memo(function ResultList({ products, query }: { products: Product[]; query: string }) { const q = query.toLowerCase() return ( <ul> {products.filter((p) => p.name.toLowerCase().includes(q)).map((p) => <li key={p.id}>{p.name}</li>)} </ul> )})တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Typing လုပ်နေစဉ် React မှာ အသစ်ကို render လုပ်ဖို့ အချိန်ရတဲ့အထိ
deferredQueryက ယခင်တန်ဖိုးကို ဆက်ထားပါတယ်။ - Input ကို အရေးပေါ်တန်ဖိုး
queryက မောင်းနှင်လို့ စာလုံးတိုင်း ချက်ချင်း ပေါ်ပါတယ်။ - List က အမီလိုက်နေတယ်ဆိုတဲ့ သက်သာတဲ့ အရိပ်အမြွက် — deferred value နောက်ကျနေစဉ် မှိန်ထားပါ။ Spinner မလိုပါဘူး။
- ဒီမှာ
memoက အရေးကြီးပါတယ် — မပါရင် parent ပြန် render ဖြစ်တိုင်း list လည်း keystroke တိုင်း ပြန် render ဖြစ်ပါလိမ့်မယ်။ ပါရင်တော့deferredQueryပြောင်းမှပဲ နောက်ကွယ်မှာ ပြန် render ဖြစ်ပါတယ်။ (React Compiler ဖွင့်ထားရင် ဒီ memoization ကို ကိုယ့်အစား ထည့်ပေးပါတယ်။)
သူ့ညီအစ်ကို startTransition (ဒါမှမဟုတ် isPending flag ပါ ပေးတဲ့ useTransition hook) က ကိုယ်တိုင် စတင်တဲ့ state update အတွက် အလားတူ လုပ်ပေးပါတယ် — tab ကြီးတစ်ခု ပြောင်းတာ၊ filter တစ်ခု အသုံးချတာလို ကုန်ကျစရိတ်များတဲ့ setState ခေါ်ဆိုမှုကို ခြုံလိုက်ရင် React က render လုပ်နေစဉ် page ကို တုံ့ပြန်မှုရှိအောင် ထိန်းထားပါတယ်။
Long task တွေကို ခွဲပါ
ဘာလဲ: event handler ထဲက loop ရှည် (file parse လုပ်တာ၊ row ထောင်ချီ sort လုပ်တာ၊ chart တည်ဆောက်တာ) က ပြီးတဲ့အထိ input တိုင်းကို ပိတ်ဆို့တဲ့ long task တစ်ခုပါ။ သူ့ကို အပိုင်းလိုက် ခွဲပြီး အကြားမှာ browser ကို yield လုပ်ပေးရင် click နဲ့ paint တွေ ကွက်လပ်ထဲမှာ ဖြစ်နိုင်ပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: စုစုပေါင်း အလုပ်က အတူတူပဲ၊ ဒါပေမဲ့ ဘယ် task တစ်ခုမှ INP ကို ထိခိုက်လောက်အောင် မရှည်တော့ပါဘူး။
// lib/yield.tstype SchedulerWithYield = { yield?: () => Promise<void> } export function yieldToMain(): Promise<void> { const scheduler = (globalThis as { scheduler?: SchedulerWithYield }).scheduler if (scheduler?.yield) return scheduler.yield() return new Promise((resolve) => setTimeout(resolve, 0))} export async function processInChunks<T>(items: T[], work: (item: T) => void, budgetMs = 40) { let start = performance.now() for (const item of items) { work(item) if (performance.now() - start > budgetMs) { await yieldToMain() start = performance.now() } }}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
scheduler.yield()က browser ကို ဆိုင်းငံ့နေတဲ့ input ကို ကိုင်တွယ်ပြီး paint လုပ်ခွင့်ပေးကာ တန်းစီနေတဲ့ တခြားအလုပ်တွေ မတိုင်ခင် ကိုယ့် code ကို ဆက်လုပ်ပါတယ်။ Chromium အခြေခံ browser တွေမှာ ထောက်ပံ့ပေမဲ့ နေရာတိုင်းမှာ မရသေးလို့ feature-detect လုပ်ထားပါတယ်။- Fallback —
setTimeout(…, 0)ကလည်း browser ကို အလှည့်ပေးပေမဲ့ ကိုယ့် code က တန်းစီနေတဲ့ တခြား task တွေနောက်မှာ စောင့်ရပါတယ်။ - Item တိုင်းပြီးတိုင်း မဟုတ်ဘဲ အပိုင်းက သူ့အချိန်ဘတ်ဂျက် (ဒီမှာ 50 ms long-task threshold အောက်ဖြစ်တဲ့ 40 ms) ကို ကုန်သွားမှပဲ yield လုပ်ပါ — yield လုပ်တာမှာလည်း ကုန်ကျစရိတ် ရှိပါတယ်။
Rendering strategy များ
Page တစ်ခုကို ဘယ်မှာ၊ ဘယ်အချိန် render လုပ်လဲ ဆိုတာက သူ့ TTFB နဲ့ LCP အများစုကို ဆုံးဖြတ်ပါတယ်။ မှန်ကန်တဲ့ ရွေးချယ်မှုကို route တစ်ခုချင်း၊ တစ်ခါတလေ component တစ်ခုချင်း လုပ်ရပါတယ်။
Static rendering နဲ့ ISR
ဘာလဲ: static route ကို build time မှာ တစ်ခါ render လုပ်ပြီး CDN ကနေ file အဖြစ် ပေးပို့ပါတယ်။ Incremental Static Regeneration (ISR) က ကိုယ်ရွေးတဲ့ အချိန်ကြာပြီးနောက် ဒါမှမဟုတ် ကိုယ်ခိုင်းတဲ့အခါ နောက်ကွယ်မှာ ပြန် render လုပ်ပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: request တိုင်းအတွက် code မ run ရလို့ static page မှာ ဖြစ်နိုင်သမျှ အမြန်ဆုံး TTFB ရှိပါတယ်။
// app/blog/[slug]/page.tsximport { notFound } from 'next/navigation'import { getAllSlugs, getPost } from '@/lib/posts' export const revalidate = 3600 export async function generateStaticParams() { const slugs = await getAllSlugs() return slugs.map((slug) => ({ slug }))} export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params const post = await getPost(slug) if (!post) notFound() return ( <article> <h1>{post.title}</h1> <p>{post.excerpt}</p> </article> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- ဒီ page ကို အများဆုံး တစ်နာရီ တစ်ခါ ပြန် render လုပ်ပါ။ တန်ဖိုးက build ဖတ်နိုင်တဲ့ ရိုးရိုး ဂဏန်း ဖြစ်ရပါမယ် (
3600ရပါတယ်၊60 * 60မရပါဘူး)။ Visitor တွေက cache လုပ်ထားတဲ့ page ကို ချက်ချင်း အမြဲရပြီး refresh ကတော့ နောက်ကွယ်မှာ ဖြစ်ပါတယ်။ - ကြိုတင် build လုပ်မယ့် slug တွေကို စာရင်းပြုစုပါတယ်။ စာရင်းထဲ မပါတဲ့ slug တွေကို ပထမဆုံး request မှာ render လုပ်ပြီး အဲဒီနောက် cache လုပ်ပါတယ်။
- Next.js 15 ကစပြီး
paramsက Promise ဖြစ်လို့ await လုပ်ရပါတယ်။
တစ်ယောက်ယောက် ပြင်တဲ့အခါမှ ပြောင်းတဲ့ content အတွက် timer အစား on demand revalidate လုပ်ပါ — data ကို tag တပ်ပြီး ပြောင်းလဲမှုကို သိမ်းတဲ့ code ကနေ revalidateTag (caching အပိုင်းမှာ အောက်မှာ ပြထားပါတယ်) ဒါမှမဟုတ် revalidatePath ကို ခေါ်ပါ။
CMS ဒါမှမဟုတ် admin ကနေ on-demand revalidation
ဘာလဲ: content ပြောင်းပြီးနောက် CMS၊ admin panel ဒါမှမဟုတ် webhook က ခေါ်တဲ့ Route Handler တစ်ခုဖြစ်ပြီး သူက revalidateTag ဒါမှမဟုတ် revalidatePath ကို ခေါ်ပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: page တွေကို အကြာကြီး (ဒါမှမဟုတ် အမြဲတမ်း) cache လုပ်ထားပြီး ပြင်ဆင်မှုပြီး စက္ကန့်ပိုင်းအတွင်း update ဖြစ်နိုင်ပါတယ်၊ ဖြစ်ကောင်းဖြစ်နိုင်လို့ဆိုပြီး အဆက်မပြတ် ပြန် render လုပ်တဲ့ timer တိုတို ရွေးစရာ မလိုတော့ပါဘူး။
// app/api/revalidate/route.tsimport { revalidatePath, revalidateTag } from 'next/cache'import { NextResponse, type NextRequest } from 'next/server' export async function POST(request: NextRequest) { if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) { return NextResponse.json({ ok: false }, { status: 401 }) } const { tag, path } = (await request.json()) as { tag?: string; path?: string } if (tag) revalidateTag(tag, 'max') if (path) revalidatePath(path) return NextResponse.json({ ok: true, tag, path })}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Header ထဲမှာ ပို့ပြီး နှစ်ဘက်လုံးရဲ့ environment variable ထဲမှာ သိမ်းထားတဲ့ မျှဝေ secret ပါ။ မပါရင် ဘယ်သူမဆို ကိုယ့် page တွေကို ကြိုက်သလောက် ပြန် render လုပ်ခိုင်းနိုင်ပါလိမ့်မယ်။
- ဒီ tag ပါတဲ့ cache လုပ်ထားတဲ့ fetch တိုင်းကို သုံးတဲ့ page တွေ မှာ refresh လုပ်ပါတယ်။
'max'နဲ့ဆိုရင် နောက် visitor က version အဟောင်းကို ချက်ချင်း ရနေဆဲဖြစ်ပြီး အသစ်ကို တည်ဆောက်နေပါတယ်။ - Page ကို သိပေမဲ့ နောက်ကွယ်က tag တွေကို မသိတဲ့အခါ route တစ်ခု (ဥပမာ
/blog/my-post) ကို refresh လုပ်ပါတယ်။
ဒီ site ကိုယ်တိုင်လည်း ဒီလိုပဲ အလုပ်လုပ်ပါတယ် — admin မှာ entry တစ်ခု သိမ်းတဲ့အခါ API က site ရဲ့ revalidate endpoint ကို ခေါ်ပြီး journal page တွေက timer ကို မစောင့်ဘဲ update ဖြစ်ပါတယ်။
Before / After: page တိုင်းကို dynamic ဖြစ်စေခဲ့တဲ့ layout
ဘာလဲ: မျှဝေသုံးတဲ့ layout ထဲမှာ cookies() ဒါမှမဟုတ် headers() ကို ဖတ်တာပါ။ ဘာကြောင့် ထိခိုက်လဲ: Cache Components မပါတဲ့ model မှာ route တစ်ခုထဲ ဘယ်နေရာမဆို request-time ဖတ်တာ တစ်ခုက route တစ်ခုလုံးကို dynamic ဖြစ်စေလို့ header ထဲမှာ နာမည်တစ်ခု print ထုတ်ဖို့အတွက်နဲ့ အဲဒီ layout အောက်က page တိုင်းကို request တိုင်း render လုပ်ရပြီး CDN-cached HTML ဆုံးရှုံးပါတယ်။
// app/(shop)/layout.tsx (before)import type { ReactNode } from 'react'import { cookies } from 'next/headers' export default async function ShopLayout({ children }: { children: ReactNode }) { const name = (await cookies()).get('user-name')?.value return ( <> <header>Hello, {name ?? 'guest'}</header> {children} </> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Layout ထဲမှာ request-time API — အခုကစပြီး
(shop)အောက်က ဘာမှ prerender မလုပ်နိုင်တော့ပါဘူး။ - Product page၊ category page နဲ့ shop ရဲ့ home page အားလုံး နှုတ်ခွန်းဆက်စကား တစ်ခုကြောင့် dynamic ဖြစ်သွားပါတယ်။
// app/(shop)/greeting.tsx (after)'use client' import { useSyncExternalStore } from 'react' const subscribe = () => () => {}const readName = () => document.cookie.match(/(?:^|; )user-name=([^;]*)/)?.[1] ?? null export function Greeting() { const name = useSyncExternalStore(subscribe, readName, () => null) return <span>Hello, {name ? decodeURIComponent(name) : 'guest'}</span>} // The layout renders <header><Greeting /></header> and no longer awaits cookies(),// so every page under it can be static again.တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Page ဖွင့်ထားစဉ် cookie မပြောင်းလို့ subscribe လုပ်စရာ မရှိပါဘူး။
- Server ပေါ်မှာနဲ့ hydration လုပ်စဉ် တတိယ function (နာမည် မရှိသေး) ကို သုံးလို့ server နဲ့ client HTML ကိုက်ညီပါတယ်၊ ပြီးမှ React က browser ထဲမှာ တကယ့် cookie ကို ဖတ်ပါတယ်။
- ကိုယ်ပိုင်အပိုင်းက client ဆီ ရွှေ့သွားပြီး ကုန်ကျစရိတ်များတဲ့ အပိုင်း — page တွေ — က static ပြန်ဖြစ်သွားပါတယ်။
Cache Components နဲ့ဆိုရင် ဒုတိယ ရွေးချယ်မှု ရှိပါတယ် — server-side cookies() ဖတ်တာကို ဆက်ထားပြီး <Suspense> နဲ့ ခြုံထားတဲ့ component သေးသေးလေးထဲ ရွှေ့ပါ။ အဲဒီအခါ layout နဲ့ page တွေက static shell ထဲမှာ ဆက်ရှိပြီး နှုတ်ခွန်းဆက်စကားပဲ stream ဝင်လာပါတယ်။
loading.tsx နဲ့ Suspense သုံးပြီး streaming
ဘာလဲ: ဘာမှ မပို့ခင် data အားလုံးကို စောင့်မယ့်အစား server က page ရဲ့ shell ကို ချက်ချင်း ပို့ပြီး နှေးတဲ့ အပိုင်းတွေကို ပြီးတာနဲ့ stream လုပ်ပေးပါတယ်။ loading.tsx က route segment တစ်ခုလုံးကို fallback နဲ့ ခြုံပေးပြီး <Suspense> က page ရဲ့ ဘယ်အပိုင်းအတွက်မဆို အတူတူ လုပ်ပေးပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: visitor က layout နဲ့ မြန်တဲ့ content ကို ချက်ချင်း မြင်ရပြီး နှေးတဲ့ query တစ်ခုက page တစ်ခုလုံးကို ဓားစာခံ မလုပ်နိုင်တော့ပါဘူး။
// app/dashboard/page.tsximport { Suspense } from 'react'import { getRevenue, getUserName } from '@/lib/data' export default async function Dashboard() { const name = await getUserName() return ( <main> <h1>Welcome back, {name}</h1> <Suspense fallback={<p className="h-40">Loading revenue...</p>}> <Revenue /> </Suspense> </main> )} async function Revenue() { const revenue = await getRevenue() // slow query return <p>Revenue this month: {revenue}</p>} // app/dashboard/loading.tsx// export default function Loading() { return <p>Loading dashboard...</p> }တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- မြန်တဲ့ data ကို ထိပ်မှာ await လုပ်ထားလို့ heading က ပထမဆုံး HTML ထဲမှာကတည်းက ပါပါတယ်။
- Fallback ကို ပထမ response ထဲမှာ ပို့ပါတယ်။ တကယ့် content နဲ့ အရွယ်အစား တူအောင်ထားရင် အစားထိုးတဲ့အခါ layout မရွှေ့ပါဘူး။
- နှေးတဲ့ query က boundary ထဲမှာ run ပါတယ်။ ပြီးတဲ့အခါ သူ့ HTML က response တစ်ခုတည်းထဲကို stream ဝင်လာပြီး fallback ကို အစားထိုးပါတယ်။
page.tsxဘေးကloading.tsxfile က page တစ်ခုလုံးကို Suspense boundary နဲ့ ကိုယ့်အစား ခြုံပေးပြီး navigation လုပ်တဲ့အခါ ချက်ချင်း ပြပါတယ်။
Cache Components နဲ့ Partial Prerendering
ဘာလဲ: Next.js 16 မှာ Cache Components (next.config.ts ထဲမှာ cacheComponents: true) က model ကို ပြောင်းလိုက်ပါတယ်။ Data fetching က default အနေနဲ့ dynamic ဖြစ်ပြီး cache လုပ်လို့ရတာကို "use cache" directive နဲ့ မှတ်သားရပါတယ်။ အဲဒီနောက် Next.js က route တိုင်းအတွက် static shell ကို prerender လုပ်ပြီး dynamic အပိုင်းတွေကို အဲဒီထဲ stream လုပ်ပေးပါတယ်။ ဒါက Partial Prerendering (PPR) ပါ — အရင်က experimental experimental.ppr flag ဖြစ်ခဲ့ပြီး အခု cacheComponents အောက်မှာ default behavior ဖြစ်သွားပါပြီ။ Flag အဟောင်းကို ဖယ်ရှားလိုက်ပါပြီ။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: page တစ်ခုက အကုန် static ဒါမှမဟုတ် အကုန် dynamic ဖြစ်စရာ မလိုတော့ပါဘူး။ Shell က CDN ကနေ ချက်ချင်း ရောက်ပြီး ကိုယ်ပိုင် ဒါမှမဟုတ် live အပိုင်းတွေပဲ server ကို စောင့်ရပါတယ်။
// app/shop/page.tsx (with cacheComponents: true in next.config.ts)import { Suspense } from 'react'import { cookies } from 'next/headers'import { cacheLife } from 'next/cache' async function getCategories(): Promise<string[]> { 'use cache' cacheLife('hours') const res = await fetch('https://api.example.com/categories') return res.json()} export default async function Shop() { const categories = await getCategories() return ( <main> <nav>{categories.map((c) => <a key={c} href={`/shop/${c}`}>{c}</a>)}</nav> <Suspense fallback={<p>Loading your cart...</p>}> <Cart /> </Suspense> </main> )} async function Cart() { const id = (await cookies()).get('cart')?.value return <p>{id ? 'Your cart is saved.' : 'Your cart is empty.'}</p>}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
"use cache"က ဒီ function ရဲ့ ရလဒ်ကို cache လုပ်လို့ရအောင် လုပ်ပါတယ်။ သူ့ argument တွေက cache key ရဲ့ အစိတ်အပိုင်း ဖြစ်သွားလို့ input မတူရင် သီးခြားစီ cache လုပ်ပါတယ်။cacheLifeက ရလဒ် ဘယ်လောက်ကြာကြာ fresh ဖြစ်နေမလဲကို built-in profile (seconds၊minutes၊hours၊days၊weeks၊max) ဒါမှမဟုတ် ကိုယ်ပိုင် profile နဲ့ သတ်မှတ်ပါတယ်။- Category တွေကို cache လုပ်ထားလို့ navigation က prerender လုပ်ထားတဲ့ static shell ရဲ့ အစိတ်အပိုင်း ဖြစ်သွားပါတယ်။
- Cart က request time မှာပဲ ရှိတဲ့ cookie တွေကို ဖတ်ပါတယ်။
<Suspense>ထဲမှာ ရှိလို့ shell ပြီးမှ stream ဝင်လာပါတယ်။ Boundary မရှိရင် dynamic data က boundary လိုတယ်လို့ development မှာ Next.js က ပြောပြပါတယ်။ cookies()က request-time API ပါ။ သူ့ကို ဖတ်တာက ဒီ component ကို dynamic အဖြစ် မှတ်သားစေပါတယ်။
Data fetching နဲ့ caching
Data ကို ဘယ်လို fetch လုပ်လဲ ဆိုတာက server ဘယ်လောက် မြန်မြန် ဖြေမလဲကို ဆုံးဖြတ်ပါတယ်။ ဒီဥပမာတွေက ရှိပြီးသား app အများစု သုံးနေတဲ့ Cache Components မပါတဲ့ model ကို သုံးထားပါတယ်။
Fetch caching နဲ့ revalidation
ဘာလဲ: Next.js က server ပေါ်မှာ fetch ကို caching option တွေနဲ့ တိုးချဲ့ထားပါတယ်။ Next.js 15 ကစပြီး default က cache မလုပ်ပါဘူး — statically prerender လုပ်တဲ့ route မှာ fetch က build time မှာ တစ်ခါ run ပြီး dynamic route မှာတော့ request တိုင်း run ပါတယ်။ cache: 'force-cache' နဲ့ opt in လုပ်ရပြီး next: { revalidate } နဲ့ သက်တမ်း သတ်မှတ်ကာ on demand refresh လုပ်နိုင်ဖို့ next: { tags } နဲ့ data ကို label တပ်ပါတယ်။
// lib/products.tsexport type Product = { id: string; name: string; price: number } export async function getProducts(): Promise<Product[]> { const res = await fetch('https://api.example.com/products', { next: { revalidate: 600, tags: ['products'] }, }) if (!res.ok) throw new Error('Failed to load products') return res.json()}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Response ကို အများဆုံး ၁၀ မိနစ် cache လုပ်ပြီး
productsလို့ label တပ်ပါတယ်။ အဲဒီ tag ပါတဲ့ fetch အားလုံးကို အတူတူ refresh လုပ်နိုင်ပါတယ်။ 200response တွေကိုပဲ cache ထဲ သိမ်းပါတယ်၊ ဒီမှာ throw လုပ်ရင် data မှားကို render လုပ်မယ့်အစား အနီးဆုံးerror.tsxက error ကို သေချာ ပြပေးပါတယ်။
// app/admin/actions.ts'use server' import { revalidateTag } from 'next/cache'import type { Product } from '@/lib/products' export async function saveProduct(product: Product) { await fetch(`https://api.example.com/products/${product.id}`, { method: 'PUT', body: JSON.stringify(product), }) revalidateTag('products', 'max')}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Server Action file တစ်ခုပါ — export လုပ်ထားတဲ့ function တိုင်း server ပေါ်မှာ run ပြီး Client Component တွေက form ကနေ ဥပမာ import လုပ်ပြီး ခေါ်နိုင်ပါတယ်။
productsdata အားလုံးကို stale အဖြစ် မှတ်ပါတယ်။ အကြံပြုထားတဲ့'max'profile နဲ့ဆိုရင် နောက် request က stale data ကို ရနေဆဲဖြစ်ပြီး နောက်ကွယ်မှာ refresh ကို စတင်ပေးပါတယ်။ ပြောင်းလဲမှုလုပ်တဲ့သူက ချက်ချင်း မြင်ရမယ်ဆိုရင် Server Action ထဲမှာပဲ အလုပ်လုပ်တဲ့updateTagကို အစားသုံးပါ။
Request memoization
ဘာလဲ: server render တစ်ခါအတွင်း တူညီတဲ့ GET fetch (URL နဲ့ option တူ) တွေကို component အများအပြားက ခေါ်ရင်တောင် တစ်ခါပဲ run ပါတယ်။ Database query နဲ့ fetch မဟုတ်တဲ့ အလုပ်တွေအတွက် function ကို React ရဲ့ cache() နဲ့ ခြုံရင် အလားတူ အကျိုးရပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: layout၊ page နဲ့ generateMetadata စတဲ့ သုံးတဲ့နေရာမှာပဲ data ကို fetch လုပ်နိုင်ပြီး သုံးခါ ပေးဆပ်စရာ မလိုပါဘူး။
// lib/user.tsimport { cache } from 'react'import { db } from '@/lib/db' export const getUser = cache(async (id: string) => { return db.user.findUnique({ where: { id } })}) // app/profile/[id]/layout.tsx, page.tsx and generateMetadata can all call// getUser(id): the query runs once per request.တစ်ကြောင်းချင်း ရှင်းလင်းချက်
cache()က argument အစုံတစ်ခုချင်းအတွက် ရလဒ်ကို request တစ်ခုအတွင်းပဲ မှတ်ထားပါတယ်။ ရေရှည် cache မဟုတ်ပါဘူး — နောက် request မှာ query ကို ပြန် run ပါတယ်။- "လိုတဲ့နေရာမှာ fetch လုပ်" ဆိုတာကို လက်တွေ့ ဖြစ်စေတာ ဒါပါ — component တွေက database query တွေ မပွားဘဲ သီးခြားစီ ရှိနေနိုင်ပါတယ်။
Waterfall အစား parallel fetching
ဘာလဲ: waterfall ဆိုတာ request တစ်ခုက သူ မမှီခိုတဲ့ တခြား request တစ်ခုကို စောင့်နေတာပါ။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: သီးခြားစီဖြစ်တဲ့ 200 ms request သုံးခုက တစ်ခုပြီးတစ်ခု ဆိုရင် 600 ms ကြာပေမဲ့ တစ်ပြိုင်နက်ဆိုရင် 200 ms လောက်ပဲ ကြာပါတယ်။ ဒါက App Router app တွေမှာ TTFB နှေးရတဲ့ အဖြစ်အများဆုံး အကြောင်းရင်းတွေထဲက တစ်ခုပါ။
// app/artist/[id]/page.tsximport { getAlbums, getArtist, getTours } from '@/lib/music' export default async function ArtistPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params // Before: each await waits for the previous one, though none depends on another // const artist = await getArtist(id) // const albums = await getAlbums(id) // const tours = await getTours(id) // After: all three start at once const [artist, albums, tours] = await Promise.all([ getArtist(id), getAlbums(id), getTours(id), ]) return ( <main> <h1>{artist.name}</h1> <p>{albums.length} albums, {tours.length} upcoming shows</p> </main> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Await သုံးခု တန်းစီနေပါတယ် — page က request သုံးခုလုံး ပေါင်းထားသလောက် ကြာပါတယ်။
- Function တွေကို ခေါ်လိုက်တာနဲ့ request တွေ စတင်ပြီး
Promise.allက အားလုံးကို တစ်ပြိုင်နက် စောင့်ပါတယ်။ အခု page က အနှေးဆုံး request တစ်ခုလောက်ပဲ ကြာပါတော့တယ်။
Fetch ကို server ပေါ်မှာ စပြီး Client Component ထဲမှာ အဆုံးသတ်ပါ
ဘာလဲ: Server Component က request တစ်ခုကို await မလုပ်ဘဲ စတင်ပြီး promise ကို Client Component ဆီ ပေးပို့နိုင်ပါတယ်၊ Client Component က <Suspense> boundary ထဲမှာ React ရဲ့ use() နဲ့ ဖွင့်ယူပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: request က server ပေါ်မှာ ဖြစ်နိုင်သမျှ စောစော စတင်ပေမဲ့ page ကို မတားဆီးပါဘူး။ Interactive chart ဒါမှမဟုတ် table က သူ့ data stream ဝင်လာတာနဲ့ render ဖြစ်ပြီး client-side fetch၊ useEffect ဒါမှမဟုတ် လက်နဲ့ စီမံရမယ့် loading state မရှိပါဘူး။
// app/dashboard/page.tsximport { Suspense } from 'react'import { getStats } from '@/lib/stats'import { StatsChart } from './stats-chart' export default function Dashboard() { const statsPromise = getStats() return ( <main> <h1>Dashboard</h1> <Suspense fallback={<div className="h-64">Loading chart...</div>}> <StatsChart dataPromise={statsPromise} /> </Suspense> </main> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
awaitမရှိပါဘူး — request က server render လုပ်နေစဉ် အခုပဲ စတင်ပြီး page က ဆက်သွားပါတယ်။- Promise က Server Component ကနေ Client Component ဆီ ပို့လို့ရတဲ့ prop ပါ။ Heading နဲ့ fallback ကို ချက်ချင်း ပို့ပြီး promise resolve ဖြစ်တဲ့အခါ chart ရဲ့ data က response တစ်ခုတည်းထဲမှာ နောက်က လိုက်လာပါတယ်။
// app/dashboard/stats-chart.tsx'use client' import { use } from 'react' type Stats = { revenue: number; orders: number } export function StatsChart({ dataPromise }: { dataPromise: Promise<Stats> }) { const stats = use(dataPromise) return ( <p> {stats.orders} orders, {stats.revenue} revenue </p> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
use()က promise resolve ဖြစ်တဲ့အထိ component ကို suspend လုပ်ထားလို့ အနီးဆုံး<Suspense>fallback က အဲဒီကြားမှာ ပေါ်ပါတယ်။ Hook တွေနဲ့ မတူဘဲ condition ထဲမှာ ခေါ်လို့ ရပါတယ်။
unstable_cache နဲ့ database query တွေကို cache လုပ်ခြင်း (model အဟောင်း)
ဘာလဲ: fetch မှာ caching ပါပြီးသားဖြစ်ပေမဲ့ database client နဲ့ SDK တွေမှာ မပါပါဘူး။ Cache Components မပါရင် unstable_cache က async function မှန်သမျှကို ခြုံပြီး fetch လိုပဲ သက်တမ်းနဲ့ tag တွေနဲ့ request တွေကြား သူ့ရလဒ်ကို သိမ်းထားပါတယ်။ သူ့အခြေအနေက အရေးကြီးပါတယ် — docs က Next.js 16 မှာ "use cache" နဲ့ အစားထိုးလိုက်ပြီ လို့ ဆိုလို့ Cache Components ဆီ မပြောင်းရသေးတဲ့ app တွေမှာပဲ သုံးပါ။
// lib/authors.tsimport { unstable_cache } from 'next/cache'import { db } from '@/lib/db' export const getAuthor = unstable_cache( async (id: string) => db.user.findUnique({ where: { id } }), ['author'], { revalidate: 3600, tags: ['authors'] },)တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Async အလုပ် မှန်သမျှ — database query၊ SDK ခေါ်တာ၊ ကုန်ကျစရိတ်များတဲ့ တွက်ချက်မှု။ Function ရဲ့ argument (ဒီမှာ
id) တွေက cache key ရဲ့ အစိတ်အပိုင်း အလိုလို ဖြစ်ပါတယ်။ - Argument တူတာ ယူနိုင်တဲ့ တခြား function တွေနဲ့ ဒီ cache ကို ခွဲခြားဖို့ key အပိုအစိတ်အပိုင်းတွေပါ။
- တစ်နာရီ fresh ဖြစ်ပြီး author တစ်ယောက်ကို ပြင်ပြီးနောက်
revalidateTag('authors', 'max')ခေါ်တိုင်း ချက်ချင်း refresh ဖြစ်ပါတယ်။
Asset optimization
Page အများစုမှာ အကြီးဆုံး byte တွေက JavaScript မဟုတ်ဘဲ image နဲ့ font တွေဖြစ်ပြီး LCP element က များသောအားဖြင့် image ဒါမှမဟုတ် web font နဲ့ heading တစ်ခုပါ။
next/image: sizes၊ preload၊ placeholder နဲ့ format
ဘာလဲ: next/image က <Image> က image တွေကို လိုအပ်သလို resize လုပ်ပေးပြီး ခေတ်မီ format တွေနဲ့ ပို့ကာ default အနေနဲ့ lazy-load လုပ်ပြီး ဘာမှ မရွှေ့အောင် နေရာ ကြိုချန်ထားပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: ဖုန်းက 3000 px မူရင်းအစား ဖုန်းအရွယ် image ကို download လုပ်ပြီး image ကြောင့် ဖြစ်တဲ့ CLS ပျောက်သွားပါတယ်။ Prop နှစ်ခုက အရေးအကြီးဆုံးပါ — image ကို ဘယ်လောက် ကျယ်ကျယ် ဆွဲမလဲ browser ကို ပြောတဲ့ sizes နဲ့ LCP ဖြစ်တဲ့ image တစ်ပုံအတွက် preload။
// app/page.tsximport Image from 'next/image'import hero from './hero.jpg' export default function Home() { return ( <main> <Image src={hero} alt="Tents by a lake at sunrise" preload sizes="100vw" placeholder="blur" className="h-[70vh] w-full object-cover" /> <div className="relative aspect-[4/3] w-full md:w-1/3"> <Image src="https://images.example.com/gear.jpg" alt="Camping gear laid out on a table" fill sizes="(min-width: 768px) 33vw, 100vw" className="object-cover" /> </div> </main> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Static import က build time မှာ width၊ height နဲ့ မှုန်ဝါးထားတဲ့ preview သေးသေးလေးကို Next.js ကို ပေးလို့ image မ load ခင်ကတည်းက နေရာ ချန်ထားပါတယ်။
preloadက<head>ထဲမှာ<link rel="preload">ထည့်ပေးလို့ browser က<img>ကို မရောက်ခင်ကတည်းက hero ကို fetch စလုပ်ပါတယ်။ Next.js 16 ကစပြီး deprecated ဖြစ်သွားတဲ့priorityကို အစားထိုးပါတယ်။ Image တစ်ပုံတည်း — LCP element — မှာပဲ သုံးပါ။sizesမပါရင် browser က image ကို viewport လောက် ကျယ်တယ်လို့ ယူဆပြီး Next.js က 1x/2xsrcsetသေးသေးလေးပဲ ထုတ်ပေးပါတယ်။ ပါရင်တော့srcsetအပြည့်ရပြီး device တစ်ခုချင်းက ကြည်လင်လောက်တဲ့ အသေးဆုံး file ကို ရွေးပါတယ်။- Image အပြည့် load ဖြစ်နေစဉ် မှုန်ဝါးတဲ့ preview ကို ပြပါတယ်။ jpg၊ png၊ webp ဒါမှမဟုတ် avif ကို static import လုပ်ရင်
blurDataURLကို အလိုလို ထုတ်ပေးပါတယ်။ fillက image ကို သူ့ parent အပြည့် ဖုံးစေပြီး parent က position သတ်မှတ်ထားကာ အရွယ်အစား ရှိရပါမယ် (ဒီမှာrelativeနဲ့ aspect ratio)။ Remote URL တွေကိုimages.remotePatternsမှာ ခွင့်ပြုထားရပါမယ်။- Image က screen ကျယ်တွေမှာ screen ရဲ့ သုံးပုံတစ်ပုံ၊ ဖုန်းမှာ အကျယ်အပြည့် ဖြစ်ပါတယ်။ ဖုန်းက desktop file ကို download မလုပ်မိအောင် ရှောင်ပေးတာ ဒီစာကြောင်းပါ။
next.config.ts ထဲမှာ images.formats ရဲ့ default က ['image/webp'] ပါ၊ browser ထောက်ပံ့ရင် AVIF ပို့ဖို့ ['image/avif', 'image/webp'] လို့ သတ်မှတ်ပါ (file ပိုသေးပေမဲ့ ပထမ request မှာ encode လုပ်ရတာ ပိုနှေးပါတယ်)။ Next.js 16 ကစပြီး images.qualities က မဖြစ်မနေ လိုအပ်တဲ့ allowlist ဖြစ်ပြီး default က [75] မို့ တခြား quality တန်ဖိုး မှန်သမျှကို အဲဒီမှာ ထည့်ရပါမယ်။
သိထားသင့်တဲ့ image setting တွေက next.config.ts ထဲမှာ ရှိပါတယ်—
// next.config.tsimport type { NextConfig } from 'next' const nextConfig: NextConfig = { images: { formats: ['image/avif', 'image/webp'], qualities: [60, 75], remotePatterns: [new URL('https://images.example.com/products/**')], },} export default nextConfigတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- လက်ခံတဲ့ browser တွေအတွက် AVIF အရင်၊ ကျန်တာအတွက် WebP။ AVIF file တွေက များသောအားဖြင့် ပိုသေးပေမဲ့ encode လုပ်ရတာ ပိုနှေးပြီး အရွယ်အစား တစ်ခုချင်းကို ပထမဆုံး တောင်းတဲ့အခါမှပဲ အဲဒါ အရေးပါပါတယ်။
qualityတန်ဖိုးတွေရဲ့ allowlist ပါ (Next.js 16 ကစပြီး မဖြစ်မနေ လိုအပ်ပါတယ်)။ နောက်ခံ ဓာတ်ပုံကြီးတွေအတွက် quality နိမ့်နိမ့်၊ ကျန်တာအားလုံးအတွက် 75။- ဒီ host နဲ့ path က image တွေပဲ optimizer ကို ဖြတ်သွားနိုင်ပါတယ်။ တတ်နိုင်သမျှ တိတိကျကျ ထားပါ — ဖွင့်ထားတဲ့ pattern က ဘယ်သူမဆို ကိုယ့် server ကို သူတို့ image resize လုပ်ဖို့ သုံးခွင့်ပေးပါတယ်။
Art direction: ဖုန်းအတွက် crop မတူတာ
ဘာလဲ: sizes က image တစ်ပုံရဲ့ အရွယ်အစား ကို ရွေးပါတယ်၊ တစ်ခါတလေ ဖုန်းက wide hero ရဲ့ အရှည်လိုက် crop လို image မတူတာ လိုပါတယ်။ getImageProps က source တစ်ခုချင်းအတွက် optimize လုပ်ထားတဲ့ srcSet ကို ပေးလို့ <picture> element ကို ကိုယ်တိုင် တည်ဆောက်နိုင်ပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: subject သေးသေးလေး ဖြစ်နေတဲ့ ချုံ့ထားတဲ့ landscape အစား ဖုန်းတွေက ပိုသေးပြီး ပိုကျစ်လျစ်တဲ့ crop ကို download လုပ်ပါတယ်။
// app/components/hero-picture.tsximport { getImageProps } from 'next/image' export function HeroPicture() { const common = { alt: 'Camp by the lake at sunrise', sizes: '100vw' } const { props: { srcSet: wide }, } = getImageProps({ ...common, src: '/hero-wide.jpg', width: 1600, height: 900 }) const { props: { srcSet: tall, ...rest }, } = getImageProps({ ...common, src: '/hero-tall.jpg', width: 800, height: 1200 }) return ( <picture> <source media="(min-width: 768px)" srcSet={wide} /> <img {...rest} srcSet={tall} loading="eager" fetchPriority="high" style={{ width: '100%', height: 'auto' }} /> </picture> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- ခေါ်ဆိုမှု တစ်ခုချင်းက
<Image>render လုပ်မယ့် prop တွေကို ပြန်ပေးပြီး အဲဒီ file အတွက် optimize လုပ်ထားတဲ့ အရွယ်အစားတွေရဲ့srcSetပါဝင်ပါတယ်။ - 768 px နဲ့ အထက် screen တွေက wide crop ကို သုံးပါတယ်။ Browser က
<source>element တွေကို အစဉ်လိုက် စစ်ပြီး ကိုက်ညီတဲ့ တစ်ခုကိုပဲ download လုပ်ပါတယ်။ - ဖုန်းတွေက tall crop ကို သုံးပါတယ်။ ဒါက hero ဖြစ်လို့ eager နဲ့ high priority နဲ့ load လုပ်ပြီး ဒါက docs က preload အစား အခြေအနေအများစုမှာ အကြံပြုထားတဲ့ နည်းပါ။
next/font: layout shift မရှိဘဲ ကိုယ်တိုင် host လုပ်တဲ့ font
ဘာလဲ: next/font က Google Fonts ကို build time မှာ download လုပ်ပြီး (ဒါမှမဟုတ် ကိုယ့် local font file တွေကို load လုပ်ပြီး) ကိုယ့် domain ကနေ ပို့ပေးပါတယ်။ Browser က Google ကို ဘယ်တော့မှ မဆက်သွယ်ပါဘူး။ Metric တူအောင် ချိန်ညှိထားတဲ့ fallback font ကိုလည်း ထုတ်ပေးပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: font host ဆီ DNS lookup ဒါမှမဟုတ် connection အပို မလိုဘဲ တကယ့် font ရောက်လာတဲ့အခါ စာသား မခုန်လို့ CLS ကို ကာကွယ်ပေးပါတယ်။
// app/layout.tsximport type { ReactNode } from 'react'import { Inter } from 'next/font/google'import localFont from 'next/font/local' const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter',}) const brand = localFont({ src: './fonts/brand-display.woff2', variable: '--font-brand', preload: false,}) export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en" className={`${inter.variable} ${brand.variable}`}> <body>{children}</body> </html> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Latin စာလုံးတွေကိုပဲ preload လုပ်ပါတယ်။ Inter က variable font ဖြစ်လို့
weightစာရင်း မလိုပါဘူး — file တစ်ခုက weight အားလုံးကို ဖုံးပါတယ်။ swapက default ပါ — စာသားက ချိန်ညှိထားတဲ့ fallback နဲ့ ချက်ချင်း ပေါ်ပြီး font ရောက်တဲ့အခါ ပြောင်းပါတယ်။- Page အနည်းငယ်မှာပဲ သုံးတဲ့ display face ကို page တိုင်းမှာ preload လုပ်စရာ မလိုပါဘူး။ Preload ပိတ်လိုက်ရင် မသုံးတဲ့ page တွေမှာ အဲဒီ download ကို ချွေတာပါတယ်။
- Font တွေကို CSS variable အဖြစ် ထုတ်ပေးထားလို့ stylesheet ဒါမှမဟုတ် Tailwind က လိုတဲ့နေရာမှာ သုံးနိုင်ပါတယ်။
Tailwind CSS v4 နဲ့ဆိုရင် next/font က CSS variable တွေက stylesheet ထဲမှာ theme font တွေ ဖြစ်လာလို့ font-sans နဲ့ font-display က self-hosted file တွေနဲ့ နေရာတိုင်းမှာ အလုပ်လုပ်ပါတယ်—
/* app/globals.css */@import 'tailwindcss'; @theme inline { --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; --font-display: var(--font-brand), Georgia, serif;}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
inlineက အရေးကြီးပါတယ် — class သုံးတဲ့နေရာမှာ variable ရဲ့ တန်ဖိုးကို သုံးဖို့ Tailwind ကို ပြောတာဖြစ်ပြီး theme တန်ဖိုးကိုယ်တိုင်က တခြား variable ကို ညွှန်တဲ့အခါ လိုအပ်တာ ဒါပါ။- Variable နောက်က system font တွေက web font load ဖြစ်နေစဉ်နဲ့ load မဖြစ်ခဲ့ရင် fallback ပါ။
next/script: third-party script တွေကို ကိုယ့်စည်းကမ်းနဲ့ load လုပ်ခြင်း
ဘာလဲ: next/script က <Script> က script တစ်ခုကို ရွေးချယ်ထားတဲ့ strategy နဲ့ load လုပ်ပါတယ်။ afterInteractive (default) က hydration တစ်စိတ်တစ်ပိုင်း ပြီးနောက် load လုပ်ပါတယ်။ lazyOnload က page resource အားလုံးပြီးနောက် browser အားတဲ့အချိန်ကို စောင့်ပါတယ်။ beforeInteractive က page hydrate မဖြစ်ခင် run ရမယ့် ရှားရှားပါးပါး script အတွက်ဖြစ်ပြီး root layout ထဲမှာ ရှိရပါမယ်။ Script ကို web worker ထဲမှာ run တဲ့ worker က experimental ပါ။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: chat widget၊ analytics နဲ့ tag manager တွေက page ပေါ်မှာ အလေးဆုံး JavaScript ဖြစ်တတ်ပြီး ဘယ်တစ်ခုမှ first paint အတွက် မလိုပါဘူး။
// app/layout.tsximport type { ReactNode } from 'react'import Script from 'next/script' export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en"> <body> {children} <Script src="https://analytics.example.com/script.js" strategy="afterInteractive" /> <Script src="https://widget.example.com/chat.js" strategy="lazyOnload" /> </body> </html> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Analytics က visit ကို စောစော မြင်သင့်ပေမဲ့ ကိုယ့် code မတိုင်ခင် မဟုတ်ပါဘူး — hydration ပြီးနောက်က မှန်ကန်တဲ့ အချိန်ပါ။
- Chat bubble က page အားတဲ့အထိ စောင့်နိုင်ပါတယ်။ ပထမ စက္ကန့်ပိုင်းအတွင်း ထွက်သွားတဲ့ visitor တွေက လုံးဝ download မလုပ်ရပါဘူး။
Navigation နဲ့ network
Link prefetching
ဘာလဲ: <Link> က link ကို viewport ထဲ ရောက်လာတဲ့အခါ သူညွှန်တဲ့ route ကို prefetch လုပ်ထားလို့ နောက် page က ချက်ချင်း ပွင့်ပါတယ်။ Static route တွေကို အပြည့် prefetch လုပ်ပြီး dynamic route တွေကိုတော့ အနီးဆုံး loading.tsx အထိပဲ လုပ်တာ ဒါမှမဟုတ် လုံးဝ မလုပ်တာပါ။ ဘာကြောင့် အရေးကြီးလဲ: prefetching က နောက် click အတွက် ကောင်းပေမဲ့ လွန်လွန်ကဲကဲ သုံးရင် first load အတွက် မကောင်းပါဘူး၊ prefetch တိုင်းက လက်ရှိ page နဲ့ ပြိုင်တဲ့ download တစ်ခုမို့ပါ။
// app/components/archive-list.tsximport Link from 'next/link' type Post = { slug: string; title: string } export function ArchiveList({ posts }: { posts: Post[] }) { return ( <ul> {posts.map((post) => ( <li key={post.slug}> <Link href={`/blog/${post.slug}`} prefetch={false}> {post.title} </Link> </li> ))} </ul> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Screen ပေါ်မှာ link ရာချီ ရှိရင် viewport prefetching က route ရာချီကို download လုပ်ပါလိမ့်မယ်။
prefetch={false}နဲ့ဆိုရင် hover လုပ်တဲ့အခါ route ကို prefetch လုပ်နေဆဲမို့ ရည်ရွယ်ချက်ရှိရှိ click က မြန်နေဆဲပါ။
ဒီ site ကိုယ်တိုင်ကနေ ထောင်ချောက်တစ်ခု — ပိတ်ထားပြီး ဖျောက်ထားပေမဲ့ layout ထဲမှာ ရှိနေဆဲ menu (display: none မဟုတ်ဘဲ inert ဒါမှမဟုတ် visibility နဲ့ ဖျောက်ထားတာ) က "viewport ထဲမှာ" လို့ ရေတွက်ခံရလို့ သူ့ link တွေနဲ့ နောက်က page တွေရဲ့ font တွေကို first load တိုင်းမှာ prefetch လုပ်ခဲ့ပါတယ်။ Menu ပွင့်မှပဲ prefetch ဖွင့်တာ (prefetch={open ? null : false}) က ပြဿနာကို ဖြေရှင်းပေးခဲ့ပါတယ်။ Cache Components သုံးတဲ့ app တွေက partialPrefetching: true ကိုလည်း ဖွင့်နိုင်ပြီး မြင်ရတဲ့ link တစ်ခုချင်းအစား route တစ်ခုကို ပြန်သုံးနိုင်တဲ့ shell တစ်ခုပဲ prefetch လုပ်ပါတယ်။
useLinkStatus နဲ့ navigation ဖြစ်နေတယ်ဆိုတာ ပြပါ
ဘာလဲ: route ကို prefetch မလုပ်ထားရင် (ဒါမှမဟုတ် loading.tsx မပါတဲ့ dynamic route ဆိုရင်) click တစ်ခုက ခဏကြာနိုင်ပြီး screen ပေါ်မှာ ဘာမှ မပြောပါဘူး။ useLinkStatus hook က <Link> ထဲက component ကို အဲဒီ link ရဲ့ navigation ဆိုင်းငံ့နေလား ပြောပြပါတယ်။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: perceived performance ပါ။ ချက်ချင်း ပေါ်လာတဲ့ အရိပ်အမြွက်လေးက စောင့်ရတာကို ပိုတိုသလို ခံစားရစေပြီး လူတွေ နှစ်ခါ click မလုပ်တော့ပါဘူး။
// app/components/nav-link.tsx'use client' import Link, { useLinkStatus } from 'next/link'import type { ReactNode } from 'react' function PendingDot() { const { pending } = useLinkStatus() return <span aria-hidden className={pending ? 'dot dot--pending' : 'dot'} />} export function NavLink({ href, children }: { href: string; children: ReactNode }) { return ( <Link href={href} prefetch={false}> {children} <PendingDot /> </Link> )}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
<Link>အတွင်း render လုပ်တဲ့ component ထဲမှာ ခေါ်ရပါမယ်၊ အဲဒီ link ရဲ့ အခြေအနေကို report လုပ်ပါတယ်။- အရိပ်အမြွက်ကို နူးညံ့အောင်ထားပြီး CSS မှာ ပေါ်လာချိန်ကို နည်းနည်း နှောင့်ထားပါ (ဥပမာ 100 ms လောက် animation delay နဲ့)၊ မြန်တဲ့ navigation တွေမှာ မလင်းလက်အောင်ပါ။
- ဒီမှာ prefetching ပိတ်ထားပါတယ်၊ docs က ဒီ hook အတွက် ဖော်ပြတဲ့ အခြေအနေပါ — navigation က server ကို စောင့်ရပြီး dot က စောင့်နေတယ်ဆိုတာ ပြပါတယ်။
Runtime၊ Proxy နဲ့ request-time code ကို ပေါ့ပါးအောင် ထားခြင်း
Next.js 16 မှာ အကြံဉာဏ်အဟောင်းတွေကို ခေတ်နောက်ကျစေတဲ့ ပြောင်းလဲမှု နှစ်ခု ရှိပါတယ်။ Route တွေအတွက် Edge runtime က deprecated ဖြစ်သွားပါပြီ — export const runtime = 'edge' ကို ဖယ်ရှားသင့်ပြီး Node.js က default ဖြစ်ကာ အကြံပြုချက်လည်း ဖြစ်ပါတယ်။ ပြီးတော့ middleware.ts က deprecated ဖြစ်ပြီး proxy.ts လို့ နာမည်ပြောင်း သွားပါပြီ။ Proxy က Node.js ပေါ်မှာ အမြဲ run ပြီး အဲဒီထဲမှာ runtime သတ်မှတ်ရင် error ဖြစ်ပါတယ်။
ဘာလဲ: Proxy က ကိုက်ညီတဲ့ request တိုင်း မတိုင်ခင် run ပါတယ်။ ဘာကြောင့် အရေးကြီးလဲ: အဲဒီထဲက နှေးတဲ့ အရာ မှန်သမျှ (database ခေါ်တာ၊ import ကြီးကြီး) က သူကိုက်ညီတဲ့ page တိုင်းရဲ့ TTFB ထဲ ပေါင်းထည့်ခံရပါတယ်။ matcher မပါရင် static file နဲ့ image request တွေအတွက်တောင် run ပါတယ်။
// proxy.tsimport { NextResponse, type NextRequest } from 'next/server' export function proxy(request: NextRequest) { const hasSession = request.cookies.has('session') if (!hasSession) { return NextResponse.redirect(new URL('/login', request.url)) } return NextResponse.next()} export const config = { matcher: ['/dashboard/:path*', '/account/:path*'],}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Function နာမည်က
proxyပါ (Next.js 16 မတိုင်ခင်ကmiddlewareပါ)။npx @next/codemod@canary middleware-to-proxy .codemod က ကိုယ့်အစား နာမည်ပြောင်းပေးပါတယ်။ - သက်သာတဲ့ စစ်ဆေးချက်ပဲ — session cookie ရှိလား? Session ကိုယ်တိုင်ကို စစ်တာ (database lookup၊ token validation) က page ဒါမှမဟုတ် Server Action ထဲမှာ ရှိသင့်ပြီး authorization ကိုလည်း docs အကြံပြုတဲ့ နေရာမှာ ထားပေးပါတယ်။
- လိုတဲ့ path တွေမှာပဲ run ပါ။
_next/staticနဲ့ image တွေ အပါအဝင် တခြား request တိုင်းက Proxy ကို လုံးဝ ကျော်ပါတယ်။
Compression နဲ့ CDN caching header များ
/_next/static/ အောက်က hash ပါတဲ့ file တွေကို public, max-age=31536000, immutable နဲ့ ပို့ပြီးသားပါ။ Static နဲ့ ISR page တွေက CDN နားလည်တဲ့ s-maxage header တွေ ရပါတယ်။ compress ကို မပိတ်ရင် next start က response တွေကို gzip နဲ့ compress လုပ်ပါတယ် (ရှေ့က proxy က brotli နဲ့ ဥပမာ compress လုပ်ပြီးသားဆိုမှပဲ ပိတ်ပါ)။ Next.js မသိနိုင်တာက public/ ထဲက ကိုယ့် file တွေ ဘယ်လောက်ကြာကြာ ပြောင်းလဲလဲ ဆိုတာပါ — သက်တမ်းရှည် cache မပါဘဲ ပို့လို့ visit တိုင်း ပြန်တောင်းပါတယ်။
// next.config.tsimport type { NextConfig } from 'next' const nextConfig: NextConfig = { async headers() { return [ { source: '/fonts/:path*', headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }], }, { source: '/images/:path*', headers: [{ key: 'Cache-Control', value: 'public, max-age=86400, stale-while-revalidate=604800' }], }, ] },} export default nextConfigတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Content ပြောင်းတိုင်း နာမည်အသစ် ရတဲ့ file တွေအတွက်ပဲ လုံခြုံပါတယ်။ အဲဒီအခါ browser က ဘယ်တော့မှ ပြန်မေးစရာ မလိုပါဘူး။
- ပြင်တဲ့အခါ နာမည်မပြောင်းတဲ့ file တွေကို သက်တမ်း ပိုတိုတို ပေးပါတယ် — တစ်ရက် fresh ဖြစ်ပြီး နောက်ကွယ်မှာ refresh လုပ်နေစဉ် တစ်ပတ်အထိ stale အဖြစ် ပို့ပါတယ်။
Build နဲ့ dev tooling
Turbopack
ဘာလဲ: Next.js ထဲ ပါဝင်တဲ့ Rust အခြေခံ bundler ဖြစ်ပြီး Next.js 16 ကစပြီး next dev နဲ့ next build အတွက် default ပါ။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: startup၊ update နဲ့ build တွေ ပိုမြန်ပါတယ်။ User တွေ download လုပ်တာကို မပြောင်းပေမဲ့ loop မြန်လေ ပိုခဏခဏ တိုင်းတာပြီး ပြင်နိုင်လေပါ။ Build က webpack ကို မှီခိုရင် (custom config ဒါမှမဟုတ် @next/bundle-analyzer လို plugin) --webpack ကို သုံးပါ။
React Compiler
ဘာလဲ: component နဲ့ value တွေကို အလိုလို memoize လုပ်ပေးတဲ့ build-time compiler ဖြစ်လို့ useMemo၊ useCallback ဒါမှမဟုတ် React.memo ကို လက်နဲ့ ရေးဖို့ ရှားသွားပါတယ်။ Next.js 16 မှာ babel-plugin-react-compiler ကို install လုပ်ပြီးနောက် top-level reactCompiler: true option နဲ့ ဖွင့်ပါတယ် (experimental အောက်မှာ မရှိတော့ပါဘူး)။ ဘာကြောင့် အထောက်အကူဖြစ်လဲ: မလိုအပ်တဲ့ re-render တွေ နည်းလာလို့ interaction များတဲ့ UI တွေမှာ INP ကောင်းလာနိုင်ပါတယ်။
// next.config.ts (after: npm install -D babel-plugin-react-compiler)import type { NextConfig } from 'next' const nextConfig: NextConfig = { reactCompiler: true,} export default nextConfigတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Build မြန်နေအောင် Next.js က compiler ကို လိုအပ်တဲ့ file (JSX ဒါမှမဟုတ် hook ပါတဲ့ file) တွေမှာပဲ သုံးပါတယ်။ မသုံးတာထက် build နည်းနည်း နှေးနိုင်ပါသေးတယ်။
အဖြစ်များတဲ့ anti-pattern များ
နှေးတဲ့ Next.js app အများစုက အကြောင်းရင်း လက်တစ်ဆုပ်စာကြောင့်ပဲ နှေးကြပါတယ်။ အောက်က တစ်ခုချင်းစီမှာ ဖြေရှင်းနည်း ပါပါတယ်။
- Page ဒါမှမဟုတ် layout ထိပ်မှာ
"use client"။ အောက်က အားလုံး client JavaScript ဖြစ်သွားပါတယ်။ ဖြေရှင်းနည်း: directive ကို interactive ဖြစ်တဲ့ အရွက်တွေဆီ ရွှေ့ပါ။ - Server render လုပ်လို့ရတဲ့ data ကို client ဘက်မှာ fetch လုပ်ခြင်း။
useEffectfetch ဆိုတာ spinner နဲ့ အသွားအပြန် နောက်တစ်ခေါက်ပါ။ ဖြေရှင်းနည်း: Server Component ထဲမှာ fetch လုပ်ပါ။ - သီးခြားစီဖြစ်တဲ့ data အတွက် တန်းစီ await။ ဖြေရှင်းနည်း:
Promise.allဒါမှမဟုတ် သီးခြား Suspense boundary တွေ။ - မရည်ရွယ်ဘဲ dynamic ဖြစ်သွားတဲ့ page တွေ။ မျှဝေသုံးတဲ့ layout ထဲက
cookies()ခေါ်တာ တစ်ခုက သူ့အောက်က page တိုင်းကို dynamic ဖြစ်စေပါတယ်။ ဖြေရှင်းနည်း: request data ကို လိုတဲ့နေရာမှာပဲ Suspense ထဲမှာ ဖတ်ပါ။ sizesမပါတဲ့ image ဒါမှမဟုတ် အရွယ်အစားမရှိတဲ့ parent နဲ့fill။ ဖုန်းတွေက desktop image ကို download လုပ်ပြီး layout တွေ ခုန်ပါတယ်။ ဖြေရှင်းနည်း:sizesကို အမြဲ သတ်မှတ်ပါ။- အရာအားလုံးကို preload ဒါမှမဟုတ် eager-load လုပ်ခြင်း။ အားလုံး ဦးစားပေးဆိုရင် ဘာမှ ဦးစားမပေးတာနဲ့ အတူတူပါပဲ။ ဖြေရှင်းနည်း: LCP image ကိုပဲ preload လုပ်ပြီး ကျန်တာကို lazy ထားပါ။
- ပထမ screen ကို lazy-load လုပ်ခြင်း။ Dynamic import လုပ်ထားတဲ့ hero က ပုံမှန်ထက် နောက်ကျမှ ရောက်ပါတယ်။ ဖြေရှင်းနည်း: fold အောက် ဒါမှမဟုတ် interaction နောက်ကွယ်က အရာတွေကိုပဲ lazy-load လုပ်ပါ။
- အလုပ်သေးသေးအတွက် dependency ကြီးကြီး။ ရက်စွဲ တစ်ခု print ထုတ်ဖို့ date library တစ်ခုလုံး။ ဖြေရှင်းနည်း:
Intlဒါမှမဟုတ် ပိုသေးတဲ့ package ကနေ တိုက်ရိုက် import။ - ထိန်းချုပ်မထားတဲ့ third-party script တွေ။ ဖြေရှင်းနည်း:
lazyOnloadနဲ့next/script၊ မသုံးတော့တာတွေကို ဖယ်ရှားပါ။ - Critical path နဲ့ ပြိုင်နေတဲ့ download တွေ။ ပထမ screen စောင့်နေတဲ့ code နဲ့ တစ်ပြိုင်နက် စတင်တဲ့ image နဲ့ prefetch တွေ။ ဖြေရှင်းနည်း: သူတို့ကို နောက်မှ စတင်ပြီး throttle လုပ်ထားတဲ့ ဖုန်းပေါ်မှာ waterfall ကို စစ်ပါ။
- လေးလံတဲ့ Proxy logic ဒါမှမဟုတ် matcher မရှိခြင်း။ ဖြေရှင်းနည်း: ကျဉ်းမြောင်းတဲ့
matcherနဲ့ သက်သာတဲ့ စစ်ဆေးချက်တွေပဲ။ next devကို တိုင်းတာခြင်း။ Development mode က optimize မလုပ်ထားပါဘူး။ ဖြေရှင်းနည်း: production build ကို တိုင်းပါ။
Before / After: client ဘက်မှာ fetch လုပ်ခြင်း
ပထမ anti-pattern က ဘေးချင်းယှဉ် ကြည့်ထိုက်ပါတယ်၊ Pages Router ဒါမှမဟုတ် single-page app ကနေ ပြောင်းလာတဲ့ app တွေမှာ အဖြစ်အများဆုံး ဖြစ်လို့ပါ—
// app/orders/page.tsx (before)'use client' import { useEffect, useState } from 'react' type Order = { id: string; total: number } export default function OrdersPage() { const [orders, setOrders] = useState<Order[] | null>(null) useEffect(() => { fetch('/api/orders') .then((r) => r.json()) .then(setOrders) }, []) if (!orders) return <p>Loading...</p> return <ul>{orders.map((o) => <li key={o.id}>{o.total}</li>)}</ul>}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Page ရဲ့ JavaScript download၊ run နဲ့ hydrate ဖြစ်ပြီးမှပဲ request စတင်နိုင်ပါတယ်။ ပြီးမှ browser ကနေ API ဆီ၊ API ကနေ database ဆီ သွားပါတယ်။
- Server-rendered HTML မှာ "Loading..." ပဲ ပါပါတယ်။ တကယ့် content နဲ့ LCP က အသွားအပြန် တစ်ခေါက်လုံး အပိုပြီးမှ ရောက်ပြီး search engine တွေ ဘယ်တော့မှ မမြင်ရနိုင်ပါဘူး။
// app/orders/page.tsx (after)import { getOrders } from '@/lib/orders' export default async function OrdersPage() { const orders = await getOrders() return <ul>{orders.map((o) => <li key={o.id}>{o.total}</li>)}</ul>}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Query က render လုပ်နေစဉ် database နဲ့ နီးတဲ့ server ပေါ်မှာ run ပါတယ်။ ဒီ page ကို data ပေးဖို့အတွက်နဲ့ API route မလိုပါဘူး။
- Order တွေက ပထမ HTML ထဲမှာ ပါပါတယ်။ Page က ကိုယ်ပိုင် JavaScript မပို့ဘဲ browser ထဲမှာ စောင့်စရာ ဘာမှ မရှိလို့ loading state လည်း မရှိပါဘူး။
ဒီ site ကနေ လက်တွေ့ ဥပမာ
နည်းလမ်းတွေကို တကယ့် case တစ်ခုနဲ့ဆိုရင် ယုံကြည်ရ ပိုလွယ်ပါတယ်။ ဒီ portfolio က ပထမဆုံး ဝင်ကြည့်တဲ့အခါ ဖုန်းမှာ နှေးသလို ခံစားရပါတယ်။ PageSpeed က စိုးရိမ်စရာ မပြပါဘူး — mobile Total Blocking Time နည်းပါတယ်။ ဒါကြောင့် ခန့်မှန်းမယ့်အစား ဖုန်းက load လုပ်သလိုပဲ page ကို record လုပ်ခဲ့ပါတယ် — 390 px viewport၊ Slow 4G နဲ့ 4× CPU slowdown၊ cache အလွတ်နဲ့။
Network waterfall က ပြဿနာကို ချက်ချင်း ပြပါတယ်။ အဖွင့် animation က မရွေ့ခင် သူ့ animation library လိုပြီး အဲဒီ library ကို first paint ပြီးပြီးချင်း တမင် load လုပ်ထားပါတယ်။ ဒါပေမဲ့ အဲဒီ အခိုက်အတန့်မှာပဲ first paint က intro အတွက် ဓာတ်ပုံ၊ အောက်ဘက်က gallery အတွက် project screenshot တွေနဲ့ ပိတ်ထားတဲ့ menu ကြောင့် journal page နဲ့ သူ့ font တွေရဲ့ prefetch ကိုပါ စတင်စေခဲ့ပါတယ်။ အားလုံး connection နှေးနှေး တစ်ခုတည်းကို မျှသုံးရလို့ animation library က စက္ကန့်ချီ နောက်ကျရောက်ပြီး screen က မှောင်နေခဲ့ပါတယ်။
ပြင်ဆင်မှုတွေက ပြောင်းလဲမှု သေးသေးလေး သုံးခုပါ၊ တစ်ခုချင်း သူ့ commit နဲ့သူ ဖြစ်ပြီး တစ်ခုချင်း တိုင်းတာခဲ့ပါတယ်—
- Intro ဓာတ်ပုံနဲ့ သူ့ project thumbnail တွေကို first paint မှာ မဟုတ်ဘဲ animation library ရောက်ပြီး နောက်မှ download စလုပ်ပါတယ်။
- Gallery ရဲ့ screenshot တွေက intro ပြီးတဲ့အထိ စောင့်ပါတယ်။
loading="lazy"တစ်ခုတည်းက သူတို့ကို မတားနိုင်ခဲ့ပါဘူး — ဖုန်းမှာ gallery က browser ရဲ့ lazy-load အကွာအဝေးထဲမှာ ရှိနေလို့ပါ။ - ပိတ်ထားတဲ့ menu ရဲ့ journal link က menu ကို ဖွင့်မှပဲ prefetch လုပ်ပါတယ် (
prefetch={open ? null : false})။
ယေဘုယျ pattern က "critical download ကို အရင်ပြီးပါစေ၊ ပြီးမှ ကျန်တာကို စပါ" ဖြစ်ပြီး စာကြောင်း အနည်းငယ်ထဲ ဝင်ဆံ့ပါတယ်—
// lib/after-critical.tslet critical: Promise<unknown> = Promise.resolve() /** Registers work the first screen waits on, such as a dynamic import. */export function markCritical(work: Promise<unknown>) { critical = Promise.all([critical, work.catch(() => undefined)])} /** Runs `start` once every critical download has settled. Returns a cancel. */export function afterCritical(start: () => void) { let live = true critical.then(() => live && start()) return () => { live = false }}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Failure တွေကို တမင် မျိုချထားပါတယ် — ပျက်နေတဲ့ critical download တစ်ခုက page ရဲ့ ကျန်တာတွေ load ဖြစ်တာကို မတားသင့်ပါဘူး။
- Component က critical အလုပ် ပြီးမှပဲ image ရဲ့
srcကို သတ်မှတ်ဖို့ ဒါမှမဟုတ် prefetch ကို ဖွင့်ဖို့ effect ထဲမှာ ဒါကို ခေါ်ပါတယ်။ - Component က အရင် unmount ဖြစ်သွားရင် effect က ရှင်းလင်းနိုင်ဖို့ cancel ပါ၊ ဒါကြောင့် မရှိတော့တဲ့ component အတွက် ဘာမှ မစတင်ပါဘူး။
အဲဒီ emulate လုပ်ထားတဲ့ ဖုန်းပေါ်မှာ interleaved run ငါးခါ (noise ကို ချေဖျက်ဖို့ build အဟောင်းနဲ့ အသစ်ကို အလှည့်ကျ) တိုင်းတာတဲ့အခါ animation က 7.2 s အစား 3.7 s မှာ စရွေ့ပြီး intro က 14.1 s အစား 10.7 s မှာ ပြီးပါတယ်။ ဒါတွေက test setup တစ်ခုပေါ်က ဒီ site ရဲ့ ဂဏန်းတွေဖြစ်ပြီး ကိုယ့် app အတွက် ကတိ မဟုတ်ပါဘူး။ ယူသွားသင့်တဲ့ သင်ခန်းစာက — critical path နဲ့ တစ်ပြိုင်နက် ဘာတွေ download ဖြစ်နေလဲ ကြည့်ပါ၊ အမြန်ဆုံး byte က သူ့အလှည့်ကို စောင့်တဲ့ byte ပါပဲ။
အလားတူ round ကနေ စတုတ္ထ ပြောင်းလဲမှုကလည်း ကူးယူထိုက်ပါတယ်။ Site ရဲ့ global stylesheet တစ်ခုတည်းက နောက်မှ load ဖြစ်တဲ့ chat widget၊ contact dialog နဲ့ sound control အတွက် style တွေကို သယ်ထားဆဲပါ။ အဲဒီ style တွေကို component ကိုယ်တိုင် import လုပ်တဲ့ file တွေထဲ ရွှေ့လိုက်တာက page တိုင်းမှာ render-blocking stylesheet ထဲက ဖယ်ပေးခဲ့ပြီး မျက်စိနဲ့ မြင်ရတဲ့ ပြောင်းလဲမှု မရှိပါဘူး။
Performance checklist
Ship မလုပ်ခင်—
- Production build ကို mobile throttling နဲ့ တိုင်းပြီး field data (PageSpeed Insights ဒါမှမဟုတ်
useReportWebVitals) ကိုပါ ကြည့်ပြီး။ - အကြီးဆုံး first-load module တွေအတွက် bundle analyzer ကို စစ်ပြီး။
"use client"က interactive အရွက် တွေမှာပဲ။- လေးလံတဲ့ ဒါမှမဟုတ် လိုမှ load လုပ်တဲ့ component တွေက Client Component ကနေ ခေါ်တဲ့
next/dynamicနောက်ကွယ်မှာ၊ လိုမှသုံးတဲ့ library တွေကို ရည်ရွယ်ချက်ပြမှimport()နဲ့ load လုပ်။ - Interactive shell တွေက server-rendered content ကို
childrenအဖြစ် လက်ခံ၊ server-only module တွေကserver-onlyကို import လုပ်။ - ကုန်ကျစရိတ်များတဲ့ render တွေက
useDeferredValueဒါမှမဟုတ် transition သုံး၊ loop ရှည်တွေက browser ကို yield လုပ်။ - အရွယ်လွန် barrel import မရှိ၊
optimizePackageImports(experimental) ကို လိုတဲ့နေရာမှာပဲ။ - Route တိုင်းက ရည်ရွယ်ချက်ရှိရှိ static၊ ISR ဒါမှမဟုတ် streamed ဖြစ်ပြီး ဘယ် layout ကမှ page တွေကို မရည်ရွယ်ဘဲ dynamic မဖြစ်စေ၊ content ပြင်တိုင်း on-demand revalidation ဖြစ်။
- သီးခြားစီဖြစ်တဲ့ request တွေက parallel၊ ထပ်ခါထပ်ခါ query တွေကို
cache()နဲ့ ခြုံထား။ - Fetch caching က ရှင်းရှင်းလင်းလင်း ဖြစ်ပြီး per-user data အတွက် ဘယ်တော့မှ shared မဟုတ်။
- Responsive
<Image>တိုင်းမှာsizesပါ၊ LCP image မှာပဲpreloadပါ။ - Font တွေက
next/fontကနေ၊ family နဲ့ weight နည်းနည်း။ - Third-party script တွေက အလုပ်ဖြစ်တဲ့ အနောက်ကျဆုံး strategy နဲ့
next/scriptကနေ။ - မမျှော်လင့်တဲ့ prefetch မရှိ၊ link စာရင်းရှည်တွေက
prefetch={false}သုံး။ proxy.tsက ကျဉ်းမြောင်းပြီး ပေါ့ပါး၊ deprecated Edge runtime ပေါ်မှာ ဘာမှ မကျန်။- မပြောင်းလဲတဲ့
public/file တွေအတွက် သက်တမ်းရှည် cache header။ - Throttle လုပ်ထားတဲ့ ဖုန်းပေါ်မှာ network waterfall ကို စစ်ပြီး။
နိဂုံး
Next.js က မြန်တဲ့ default တွေ ပေးထားပါတယ် — Server Component၊ automatic code splitting၊ image နဲ့ font pipeline၊ prefetching နဲ့ CDN နဲ့ အဆင်ပြေတဲ့ cache။ Performance အလုပ် အများစုက အဲဒီ default တွေကို ဆန့်ကျင်မနေဖို့ပါ၊ ပြီးတော့ ကိုယ်ပဲ ဆုံးဖြတ်နိုင်တဲ့ ဆုံးဖြတ်ချက် အနည်းငယ်အကြောင်းပါ — ဘာက interactive ဖြစ်ရမလဲ၊ ဘာကို ဘယ်လောက်ကြာကြာ cache လုပ်နိုင်လဲ၊ ဘယ် image က hero လဲ၊ ပထမ screen က ဘယ် download တွေကို တကယ် စောင့်နေလဲ။
Loop နဲ့ အလုပ်လုပ်ပါ။ Throttle လုပ်ထားတဲ့ ဖုန်းပေါ်မှာ production build ကို တိုင်းပါ၊ အကြီးဆုံး ပြဿနာကို ရှာပါ၊ အဲဒီ တစ်ခုကို ပြင်ပါ၊ ပြန်တိုင်းပါ။ ကွာခြားမှု ရှိတဲ့ ပြောင်းလဲမှုတွေကို ထားပြီး မရှိတာတွေကို ပြန်ဖျက်ပါ။ ဒီဟာ အပါအဝင် ဘယ်နည်းလမ်းစာရင်းကမှ ကိုယ့် app ကို ကိုယ့်ကိုယ်ပိုင် တိုင်းတာချက်တွေလောက် မသိပါဘူး။

