🔥 My NextJS Handbook

Table of contents
- Key Difference Between Next.js and React
- Dynamic Imports
- Understanding Next.js Rendering Strategies — SSG, CSG, SSG, and ISR
- How to Client-Side-Render a Component in Next.js
- Serverless Functions in Next.js
- You must use middleware like this in Next.js
- Key Considerations for Next.js App Router Files
- Next.js Server Actions Lessons Learned
- When To Use React Query With Next.js Server Components
- When Should I Use Server Action (Next.js 14)
- How I Made a Next.js App Load 10x Faster
- Ditch Useless useEffect Calls 🚫
- Use Image Optimization (next/image) 📷
- Reduce JavaScript (Move Logic to the Server)
- Use Static Generation When Possible (getStaticProps)
- Optimize Fonts & Reduce CLS (Cumulative Layout Shift)
- Hybrid Rendering in Next.js: Mixing SSG, SSR, and ISR for Ultimate Flexibility
- unstable_cache in Action: Smarter, Faster Data Fetching
- Analyze your bundle size with @next/bundle-analyzer
- Use Dynamic Imports to Load Components Lazily
- Use Middleware for Specific Routes Only
- Tree Shake Your Code
- Set proper HTTP Headers for Caching
- Use Edge Functions or Low Latency
- Clean Up Third-Party Scripts
- Conclusion
- References

Next.js is a React-based framework that allows you to build server-side-rendered applications with ease. With Next.js, you can create dynamic and fast-loading web pages that are optimized for search engines and social media platforms. Some of the key benefits of using Next.js include:
Automatic code splitting for faster page loads.
Server-side rendering for improved SEO and performance.
Built-in support for CSS modules and styled components.
Easy deployment with Vercel, the platform that was built specifically for Next.js.
Today, I’m going to explain some advanced concepts of Next.js that most developers don’t know. You can use them to optimize your App and improve the Developer experience.
Key Difference Between Next.js and React
Rendering Methods: client-side vs server-side
When we talk about the rendering method in React, React relies mainly on client-side rendering (CSR). Therefore, both the logic and the structure of the web page will be handled by the browser (client). Though this method is commonly used, it has some downsides, effects as slower initial page load.
Next.js, on the other hand, supports both SSR and CSR because it was built on top of React. Web pages are rendered on the server, and both the logic and structure of the page are handled by the server. This enables faster page loading and improves SEO.
Performance Considerations
In terms of performance considerations, Next.js is often preferred because it offers multiple rendering options, including server-side rendering (SSR), static site generation (SSG), Incremental static regeneration (ISR), and client-side rendering(CSR). In contrast, React primarily provides a single rendering approach: client-side rendering.
SEO Implications
React is less SEO-friendly because search engines may struggle to index content that requires JavaScript to render.
On the other hand, Next.js is more SEO-friendly than React because it renders content on the server, providing search engines with fully rendered content for easier indexing.
Scaleability and Project Complexity
In terms of scalability and project complexity, Next.js is generally better than React. Next.js provides built-in features that enhance the scalability of your project. These include:
Server-side rendering and static site generation for better performance and SEO.
A built-in API route features for creating serverless functions seamlessly.
A file-based routing system that simplifies the organization of larger projects.
In contrast, with React, you are responsible for setting up and maintaining the structure for scalability. For larger projects, this often requires adding additional tools such as:
State management libraries (for example, Redux, Recoil, and so on).
Routing libraries (for example, React Router).
These tools are necessary to enhance React’s scalability and address project complexity, but they also increase the overhead and effort needed to set up and manage the application.
In summary, here is a tabular breakdown:
Factors | React | Next.js |
Scalability | It is possible, but to increase scalability, it requires additional tools and a custom setup | It is scalable and already has built-in tools that increase its scalability. |
Performance | It provides only one rendering option, which is client-side rendering (CSR) | It offers multiple rendering options, including SSR, SSG, ISR, and CSR. |
SEO | It is less SEO-friendly because search engines may struggle to index content that requires JavaScript execution to render. | It is more SEO-friendly than React because it renders content on the server, providing fully-rendered HTML to search engines for easier indexing. |
Use Case | Mostly used in smaller or unique projects | Mostly used in large-scale projects, and enhances performance and SEO |
Dynamic Imports
Dynamic imports are a modern JavaScript feature that enables developers to split the code into smaller bundles, permitting the loading of various parts (chunks) of your application on demand. This feature can significantly improve your app's performance by reducing load time, especially for large and complex applications.
import dynamic from 'next/dynamic';
// Dynamically import the component
const DynamicComponent = dynamic(
() => import('../components/DynamicComponent'),
{
ssr: false, // Disable server-side rendering for this component
}
);
const PageComponent = () => (
<div>
<p>This is a static content</p>
<DynamicComponent />
</div>
);
export default PageComponent;
In the above example, the DynamicComponent
will only be loaded when the PageComponent
is rendered, thus significantly decreasing the initial loading time for your application.
Understanding Next.js Rendering Strategies — SSG, CSG, SSG, and ISR
One of the advantages of using Next.js is the versatility in regard to how it builds the page on the user’s browser. If you can comprehend how these strategies work, you will have an easier time building a faster and more efficient site.
Next.js has four rendering strategies:
Server-Side Rendering (SSR)
Client-Side Rendering (CSR)
Static-Site Generation (SSG)
Incremental-Statis Regeneration (ISR)
I will explain each strategy, including the process behind the scenes, these use cases, and the pros and cons.
Server-side Rendering
How it works:
The Js file is sent to the server
When the user (browser) requests, the server will run the
getServerSideProps()
function (Next 12) orfetch(‘https://...', { cache: ‘no-store’ });
(Next 13)After the data is fetched, it will be built on the server (including the data from the API)
The server will send the HTML to the user’s browser.
Use cases:
if your site data is updated frequently
At the same time, SEO is an essential factor as well
Pros
No loading
Real-time data
Good for SEO
It can be used for personalized content
Cons
The user needs to wait for the HTML to be built on the server side
Too much burden on the server => every user’s request, the server to be rebuilt.
Client-side Rendering
How it works
Build Process => The HTML is sent to the server
When the user requests, the server sends the HTML file, and then the client (browser) requests the data from the API server.
At the time the client is requesting the data from the API server, the browser will display the loading state (in most cases)
After the data is fetched from the API server, the loading state will be turned off, and the screen will be updated with the data from the API
Use case:
If your site’s data is updated frequently.
SEO is not an essential factor.
Pros
Real-time data
It can be used to personalize content
The burden is not too big for the server
Cons
There will be a loading state
Not good for SEO => Since HTML is built from the server, it does not include the data from the API
Static-Site Generation (SSG)
How it works:
The build and fetch data processes happen at the same time =>
getStaticProps()
function (Next 12) orfetch()
function (it’s set as default in Next 13) is running at build timeAfter the HTML + JSON is built (the data from the API is included), it will be sent to the server.
When the user (browser) requests, the server will send the HTML + JSON file, so the user doesn't need to wait (no loading).
Use case:
if your site data is definite (fetch one, and that’s it)
Example: site for Al-Qur’an, logs/history data, or old archived documents, which won’t be changed no matter what
Pros
Overall, the fastest method
No loading
The burden is not too big for the server
Good for SEO
Cons
Don’t have the trigger to update the data from API(fetch once, and that’s it) unless it’s redeployed
It can’t be used as personalized content => because this method doesn't have any way to update the built HTML file from the server (so whenever the user requests, it will remain the same)
Incremental Static Regeneration (ISR)
How it works:
Pretty much the same with SSG, but with the capability to update the data
If you set the revalidated data on the
getStaticProps()
function (Next 12) orfetch(‘
https://.../data', { next: { rev
alidate: 10 } });
(Next 13), The server will revalidate the data from the API and see whether there is any change.If there is any update from the API (DB), then the built HTML file will be updated (the new one will override the current one)
But it will only be updated after the set time, in the revalidate props time is passed
Example: If you setrevalidate: 10
=> It will do the revalidation and update the HTML after 10 seconds have passed.
Use case:
- Best practice for most static sites that don’t need a real-time update
Pros
- Basically, it’s SSG (fast + good for SEO!) but with an additional tweak to be able to update the site’s data
Cons
It can’t be used as personalized content.
It can’t be used if you have a real-time feature on your site.
How to Client-Side-Render a Component in Next.js
Whether something in Next.js is client-side or server-side rendered can be easily determined. We will work with this simple component:
<>
<p>Hello World!</p>
</>
Using Next.js’ default server-side rendering, the generated HTML looks like this:
You can see the component’s XML is rendered on the server.
Meanwhile, using client-side rendering, the initial HTML response looks like this:
Of course, the “Hello World!“ still appears in the DOM, but it takes some time because it is rendered through client-side JavaScript.
Here are 3 ways to achieve CSR in Next.js:
Method 1: Timing with useEffect
If you use Next.js’s new App Router, every component is a server component by default and can’t use React hooks. Therefore, we declare it as a client component on top by stating “use client“
.
'use client'
import { useEffect, useState } from 'react'
export default function Index() {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
if (!isMounted) {
return <p>loading...</p>
}
return (
<>
<p>Hello World!</p>
</>
)
}
Method 2: Dynamic components
Using a dynamic import, a component can also be rendered only on the client side. The reason is simple: The component is imported once the wrapping component is rendered; therefore, all the work is happening on the client.
import dynamic from 'next/dynamic'
const HelloWorld = dynamic(() => import('../components/HelloWorld'), {
ssr: false,
})
export default function Index() {
return <HelloWorld />
}
This approach works both for clients and for server-side components.
Method 3: Use the window object (Only Pages Router)
Hint: This approach doesn’t work in the new App Router
This trick is so simple: When we render something on the server, the window object isn’t available in our code. Why? Well, because the window object is exclusive to the browser, of course.
Since we can access this special object in Next.js, we check if we are on the server or not. Therefore, we can save if server-side rendering is happening in a variable like this:
const SSR = typeof window === 'undefined'
SSR is true if server-side rendering of our JSX is happening. To only client-side render something, use this variable:
const SSR = typeof window == 'undefined'
export default function Index() {
return <>{!SSR ? <p>Hello World!</p> : null}</>
}
Serverless Functions in Next.js
Serverless functions are a great way to implement backend functionality without managing a server. Next.js provides built-in support for serverless functions using the API directory.
To create a serverless function in Next.js, create a file in the pages/api
directory with the following format.
// pages/api/hello.js
export default function handler(req, res) {
res.status(200).json({ message: 'Hello, world!' });
}
This function will respond to requests to /api/hello
with a JSON message.
You can also use serverless functions to handle data from forms or other input fields. Here’s an example of a serverless function that handles a form submission:
// pages/api/contact.js
import nodemailer from 'nodemailer';
export default function handler(req, res) {
const { name, email, message } = req.body;
// Send email using nodemailer
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: 'your_email@gmail.com',
pass: 'your_password',
},
});
const mailOptions = {
from: email,
to: 'recipient_email@example.com',
subject: `New message from ${name}`,
text: message,
};
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
console.log(error);
res.status(500).send('Error sending message');
} else {
console.log(`Email sent: ${info.response}`);
res.status(200).send('Message sent');
}
});
}
In this example, we are using a nodemailer
library to send an email with the form data. This serverless function can be triggered by a form submission in your application.
You must use middleware like this in Next.js
Middleware in Next.js is often overlooked, but it’s a game-changer once you understand its potential. If you are not using it yet, you might be missing out on one of the most powerful tools Next.js has to offer.
What is middleware in Next.js?
Let’s break down the essentials of middleware in Next.js:
It’s simply a function - at its core, middleware is just a function
Executed on the edge - middleware runs on the edge, closer to the user
Runs for specified pages - you decide which pages it affects through middleware’s configuration.
Executes before page load - middleware runs before the user receives the page.
Takes in the request - It accepts the page’s GET request object as a parameter.
Middleware doesn’t impact the rendering pattern, and most importantly, it doesn't interfere with the page’s rendering approach.
This image perfectly illustrates the concept of middleware from a networking perspective.
The problem
Imagine you have a “My Profile“ page that should only be accessible to authenticated users. If someone isn’t logged in, we want to redirect them to the Sign-In page.
How do we handle this? Checking for a token in the local storage or a NextAuth session on the client side might seem straightforward, but here’s the catch:
User experience suffers — An authenticated user has to wait for the page to load and the client-side code to execute before being redirected. During this delay, they see a loading state, which is less than ideal. Even authenticated users end up seeing this loading state unnecessarily, leading to a frustrating user experience.
Bundling and efficiency issues — Using client-side checks means adding extra code to all private pages. This approach can work in simple applications, but becomes inefficient if additional libraries are required, resulting in a larger bundle size.
Alternatively, you might consider handling the session check on the server side. While this can streamline the process, it forces every protected page to become dynamic, which presents a challenge if that is not your goal.
Now, imagine if there were a way to detect user authentication as soon as they request the page and redirect them to the sign-in page without impacting the rendering pattern. That’s where an efficient solution comes into play.
Once I discovered the power of middleware, I immediately integrated it into my project. But before diving into the code, let me clarify a few key points. The NextAuth library conveniently places all relevant information from the token directly into the req
object, allowing us to access it effortlessly via req.nextauth.token
. This means I can extract not only authentication details but also user roles and authentication statuses.
Now, let’s take a look at how this all comes together:
import { withAuth } from "next-auth/middleware";
const authRoutes = [AppRoutes.signIn, AppRoutes.signUp, AppRoutes.forgotPassword]
export default withAuth(
async function middleware(req) {
const user = req.nextauth.token?.user
const isSigned = Boolean(user)
const isResetPasswordRoute = req.nextUrl.pathname.startsWith(
AppRoutes.resetPassword("")
)
const isAuthRoute = authRoutes.includes(req.nextUrl.pathname) || isResetPasswordRoute
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin")
const isSubscriptionPlansRoute = req.nextUrl.pathname === AppRoutes.subscriptionPlans
const isAdminUser = req.nextauth.token?.user.role === USER_ROLE.ADMIN
const accountStatus = req.nextauth.token?.user.accountStatus ?? ""
const isPaid = accountStatusesWithAppAccess.includes(accountStatus) || isAdminUser
const hasSubscription =
accountStatusesThatHasSubscription.includes(accountStatus) || isAdminUser
const isMustGoPlansRoute = !hasSubscription && isSigned
const isRenewSubscriptionRoute = req.nextUrl.pathname === AppRoutes.renewSubscription
const mustGoRenewSubscription = hasSubscription && !isPaid && isSigned
// if user have trial or subscription, but by some reason it is paused then redirect to renew it
if (mustGoRenewSubscription && !isRenewSubscriptionRoute) {
return NextResponse.redirect(new URL(AppRoutes.renewSubscription, req.url))
}
// if user shouldn't go to renew subscription, then restrict access to it
if (!mustGoRenewSubscription && isRenewSubscriptionRoute) {
const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn
return NextResponse.redirect(new URL(redirectRoute, req.url))
}
// if user signed and has no subscription or trial then he must go and choose plan
if (!isSubscriptionPlansRoute && isMustGoPlansRoute) {
return NextResponse.redirect(new URL(AppRoutes.subscriptionPlans, req.url))
}
// if user shouldn't go to chose plan, then restrict access to it
if (isSubscriptionPlansRoute && !isMustGoPlansRoute) {
const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn
return NextResponse.redirect(new URL(redirectRoute, req.url))
}
// if user signed and paid and trying to go auth route redirect main
if (isAuthRoute && isSigned && isPaid) {
return NextResponse.redirect(new URL(AppRoutes.main, req.url))
}
// if not signed user trying to go to protected route redirect sign-in
if (!isSigned && !isAuthRoute) {
return NextResponse.redirect(new URL(AppRoutes.signIn, req.url))
}
// if user is not admin and trying to go admin route redirect main or sign-in
if (isAdminRoute && !isAdminUser) {
const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn
return NextResponse.redirect(new URL(redirectRoute, req.url))
}
},
{
secret: envServer.NEXTAUTH_SECRET,
callbacks: {
authorized: () => true,
},
}
)
Uh-huh, functions seem a bit too large, don't they?
I’m well aware of that, and I recognize the need for improvement. Inspired by a solution I encountered in a Vue.js project, I set out to create something similar.
Advanced Approach
I was thinking about the most convenient way for me to handle whether the user can access the route or not. I have the same condition for several routes, but sometimes routes have the same condition as another one, but with one more condition. Also, some conditions have more priority than others, so we need to handle this too.
Would it be great to define conditions for each route separately? Let’s try to do this.
First of all, let’s create the desired structure. I prefer an array of objects with routes and conditions, something like this:
[
{route: "/onboarding", condition: (token: JWT, url: string) => {}},
{route: "/profile", condition: (token: JWT, url: string) => {}}
]
Ok, it works, but what should this condition return?
In case the condition needs to redirect the user, then it must return a redirect.
Otherwise, let's return just null.
[
{
route: "/onboarding",
condition: (token: JWT, url: string) => {
if (!token?.user) return null;
return NextResponse.redirect(new URL("/sign-in", url))
}
},
{
route: "/profile",
condition: (token: JWT, url: string) => {
if (!token?.user) return null;
return NextResponse.redirect(new URL("/sign-in", url))
}
},
]
Great! It works, but we have 2 issues here.
How are we gonna run it?
The same condition is duplicated
Let’s start with the first one.
import type { NextRequestWithAuth } from "next-auth/middleware";
export type RouteConfig = {
condition: (token: JWT) => NextResponse<any> | null;
url: string;
};
export function runRoutesMiddleware(
req: NextRequestWithAuth,
config: RouteConfig[]
): NextResponse<any> | null {
const currentRouteConfig = config.find((route) =>
matchPath(route?.url, props?.nextUrl)
);
if (!currentRouteConfig) return null;
return currentRouteConfig.condition(req.token);
}
export default withAuth(
async function middleware(req) {
return runRoutesMiddleware(req, routesRulesConfig);
},
{
secret: env.NEXTAUTH_SECRET,
callbacks: {
authorized: () => true,
},
}
);
Here, I use a small hack called “matchPath“. It’s the same function that we have in the react-router. but returns a boolean if the URL matches the pattern. matchPath(“/user/:id”, “user/123”) -> true.
Let’s solve the duplicated condition issue by separating the condition from the config. Also, we need to have some reusable functions that will allow us to create complex functions from small pieces. Let's call this small function “Rule“. It will have the same type we saw before for condition.
type Rule = (token: JWT) => NextResponse<any> | null;
We need some functions to call these rules until it gets a response from one of them. Here it is.
export function executeRules(
rules: Rule[],
url: string,
token: JWT,
ruleIndex: number = 0
): ReturnType<Rule> | void {
if (ruleIndex > (rules?.length || 0) - 1) return;
const result = rules?.[ruleIndex]?.(token, url);
if (!result) {
return executeRules(rules, url, token, ruleIndex + 1);
} else {
return result;
}
}
And adjust our runRoutesMiddleware
function
export function runRoutesMiddleware(
req: NextRequestWithAuth,
config: RouteConfig[]
): NextResponse<any> | void {
const currentRouteConfig = config.find((route) =>
matchPath(route?.url, props?.nextUrl)
);
if (!currentRouteConfig) return;
return executeRules(
currentRouteConfig?.rules,
req.url,
req.nextauth.token
);
}
Let’s create our rule
export const isAuthenticatedRule: Rule = (token, url) => {
if (!token?.user) return null;
return NextResponse.redirect(new URL("/sign-in", url))
}
Then use it in our config
[
{
route: "/onboarding",
rules: [isAuthenticatedRule]
},
{
route: "/profile",
rules: [isAuthenticatedRule]
},
]
That’s it! Now we have a very simple flow for creation, editing, deletion and reprioritisation of our rules for every page. Sounds good, doesn’t it?
Key Considerations for Next.js App Router Files
The Next.js App Router, introduced in Next.js App 13, represents a shift from the previous pages directory approach. It uses folders in the app directory to define routes.
This new system supports layout, loading states, and error handling at the route level. It also allows the seamless mixing of client and server components, offering improved performance and a more flexible development experience for the first-load approach.
Now, let’s dive into each of these special files by checking out this article.
Next.js Server Actions Lessons Learned
I have been building web apps for years, and one thing that has always been a pain is managing that messy back-and-forth between the client and the server. Next.js made things easier, especially with server-side rendering, but it still felt like there was a missing piece. Then came Server Actions, when I first heard about them, I was skeptical — “Server code directly in React components? Sounds like a recipe for disaster“. But after using them for a few projects, I’m a convert. This section is my brain dumb on Server Actions — how they work, what they matter, and when they can be useful (and when they might be trouble than they are worth).
What are the Next.js Server Actions?
So, what exactly are these Server Actions? Basically, they are the way to write server-side code — the stuff that is used to live in separate API routes — right inside your React components. Instead of creating a separate file for server interactions or business logic, you can now directly put that logic where they are being used.
The secret sauce is the “use server“ directive. Think of it as a tag that tells Next.js, “run this code on the server“. You can tag the entire file or just specific functions. Here’s a quick example.
// app/actions.ts
"use server";
export async function addItemToCart(itemId: string, quantity: number) {
// This runs on the server
console.log(`Adding ${quantity} of item ${itemId} to the cart...`);
}
Now, if you’ve used Next.js before, you might be thinking, “Wait, isn’t that what API routes are for?” And yes, API routes have been the traditional way to handle server stuff. But Server Actions are different. They are more tightly integrated with your components. Instead of separate files and a bunch of fetch calls, you can have the logic right there, next to your UI.
Of course, Server Actions aren’t meant to replace API routes entirely. If you are building an public API or need to talk to the external services, API Routes are still a way to go. But for those common tasks that are deeply tied to your UI, especially data mutations, Server Actions can be a game-changer. They are like specialized tools in your toolbox, not a replacement for the whole toolbox.
How does Next.js Server Actions Work Under The Hood
Understanding the underlying mechanisms is the key to using them effectively, and, of course, debugging them when things go wrong.
First up, that "use server"
directive. As we touched upon, it’s your way of telling Next.js what code should run on the server. You can either put it at the top of the file, which makes every exported function in that file a Server Action, or you can add it to individual functions. Generally, it's cleaner to keep Server Actions in dedicated files. It makes things more organized. Here is an example of a file with multiple Server Actions:
// app/actions/products.ts
"use server";
export async function addProduct(data: ProductData) {
// ... runs on the server
}
export async function deleteProduct(productId: string) {
// ... also runs on the server
}
Now, when you call a Server Action from a client component, it’s not just a regular function call. Next.js does some magic behind the scenes — it’s like an RPC (Remote Procedure Call) process. Here’s the breakdown: your client code calls the Server Action function. Next.js then serializes the arguments you passed — basically, converting them into a format that can be sent over the network. Then, a POST
request is fired off to a special Next.js endpoint, with the serialized data and some extra info to identify the Server Action. The server receives the request, figures out which Server Action to run, deserializes arguments, and executes the code. The server then serializes the returned value and sends it back to the client. The client receives the response, deserializes it, and — this is the cool part — automatically re-renders the relevant parts of your UI.
The serialization part is where things get interesting. We’re not just dealing with simple strings and numbers here. What if you need to pass a Date
object or a Map
? Next.js handles the serialization and deserialization. Here is an example to demonstrate that:
// app/actions/data.ts
"use server";
export async function processData(date: Date, data: Map<string, string>) {
console.log("Date:", date); // Correctly receives the Date object
console.log("Data:", data); // Correctly receives the Map object
return { updated: true };
}
Server Actions are tightly integrated with React’s rendering. For instance, you can hook a Server Action directly to a form submission using the action
attribute. Next.js handles all the messy details for you. Like this:
// app/components/MyForm.tsx
"use client"
import { myServerAction } from '@/app/actions';
export default function MyForm() {
return (
<form action={myServerAction}>
{/* Form fields */}
<button type="submit">Submit</button>
</form>
);
}
Or, if you want more control, just call the Server Action from an event handler:
"use client"
import { myServerAction } from '@/app/actions';
export default function MyComponent() {
const handleClick = async() => {
const result = await myServerAction();
// Handle the result
}
return <button onClick={handleClick}>Click Me</button>
}
Anh, the best part? After the Server Action completes, Next.js automatically re-renders the parts of your UI that might have changed because of it. No more manually fetching data or updating the state after a mutation. It just works if the user doesn’t have JavaScript enabled or if it’s still loading; forms with Server Actions will work as regular HTML forms. Once JS is available, it will be enhanced by Next.js.
Here’s a diagram to visualize the process:
Now, Server Actions aren’t a magic bullet, and I’ve run into a few gotchas, which we’ll get to later. But they do streamline a lot of the tedious work involved in client-server communication.
Why Do Server Actions Matter in the Current Landscape?
Let’s be real, the world of web development is constantly throwing new things at us. So, why should we care about Server Actions? Here’s the deal: building modern web apps is complicated. We want these rich, interactive experiences, but managing the communication between the client and server can be a real pain. We often end up spending more time on the plumbing — API routes, data fetching, state management — than on the actual features users care about.
Server Actions tackle this problem head-on. By letting us put server-side code right in our React components, they drastically simplify things. Think about it: no more separate API route files, no more manually fetching data after a mutation. Your code becomes more concise and easier to follow, especially for smaller teams or solo developers. I’ve found that on smaller projects, Server Actions have cut down development time significantly.
And it’s not just about convenience. Server Actions can also boost performance. By reducing those back-and-forth trips between the client and server, especially for things like updating data, we can make our apps feel snappier. Fewer network requests mean faster loading times, and that’s a win for user experience. Plus, they play nicely with Next.js’s caching features, so you can optimize things even further.
Security is another big win. With Server Actions, sensitive operations — database queries, API calls with secret keys, etc. — stay on the server. That’s a huge relief in today’s world of increasing security threats. Also, they are always invoked with POST
request.
Server Actions are also part of a bigger trend. Full-stack frameworks like Next.js are blurring the lines between frontend and backend. Server Actions are a natural step in that direction, letting developers handle more of the application lifecycle without needing to be a backend guru. This doesn’t mean specialized roles are going away, but it does mean that full-stack developers can be more efficient and productive.
Now, I’m not saying Server Actions are perfect or that they should replace every other way of doing things. But they do offer a powerful new approach, especially for data-heavy applications. They’re a significant step forward for Next.js and, in my opinion, for full-stack development in general.
The Caveats and Criticisms of Server Actions: A Reality Check
Like any technology, they have their downsides, and it’s important to go in with eyes wide open. I’ve learned a few things the hard way, and I’m here to share them.
One of the biggest criticisms is the potential for tight coupling. When your server-side code lives right inside your components, it’s easy to end up with a less modular, harder-to-maintain codebase. Changes to your backend logic might force you to update your frontend, and vice versa. For complex projects or teams that need a strict separation of concerns, this can be a real problem. You need to be disciplined and organized to prevent your codebase from becoming a tangled mess.
Then there’s the learning curve. While the basic idea of Server Actions is simple, mastering all the nuances — serialization, caching, error handling — takes time. You need to really understand the difference between client and server code execution and how to structure your actions for optimal performance and security. The mental model is different, and it takes some getting used to.
Debugging can also be a pain. When something goes wrong in a Server Action, you can’t just rely on your trusty browser dev tools. You’ll need to get comfortable with server-side debugging techniques — logging, tracing, and so on. Next.js has improved its error messages, but it’s still more complex than debugging client-side code.
Performance is generally a plus with Server Actions, but if you overuse them, you can actually make things worse. Every Server Action call is a network request. Too many requests and your app will feel sluggish. Next.js’s caching helps, but you need to be strategic about it. They’re great for handling data mutations but might not be ideal for complex queries or aggregations.
Finally, there’s the issue of vendor lock-in. Server Actions are a Next.js thing. If you decide to move away from Next.js in the future, you’ll have to rewrite all your Server Actions. That’s something to consider, especially if you’re worried about long-term flexibility.
So, are Server Actions worth it despite these drawbacks? In my opinion, yes, but they’re not a magic solution. You need to use them thoughtfully and understand their limitations. They’re a powerful tool, but like any tool, they can be misused. They are best used for data mutations and operations that are tightly coupled to your UI and need to be on the server.
Real-World Example: Add to Cart
Let’s see how Server Actions can be applied in a real-world scenario. Imagine we’re building an e-commerce platform, and we need a feature to add products to a shopping cart. Here’s how we could implement it using a Server Action, incorporating some crucial best practices along the way.
// app/actions.ts
"use server";
import { db } from "@/lib/db"; // Your database client
import { revalidatePath } from "next/cache";
export async function addItemToCart(userId: string, productId: string, quantity: number) {
try {
// Input validation
if (!userId || !productId || quantity <= 0) {
throw new Error("Invalid input data");
}
// Check for product existence
const product = await db.product.findUnique({
where: { id: productId },
});
if (!product) {
throw new Error("Product not found");
}
// Handle the cart item
const existingCartItem = await db.cartItem.findFirst({
where: { userId, productId },
});
if (existingCartItem) {
await db.cartItem.update({
where: { id: existingCartItem.id },
data: { quantity: existingCartItem.quantity + quantity },
});
} else {
await db.cartItem.create({
data: { userId, productId, quantity },
});
}
// Cache revalidation to reflect the changes on the pages
revalidatePath(`/products/${productId}`);
revalidatePath(`/cart`);
return { success: true, message: "Item added to cart" };
} catch (error) {
console.error("Error adding item to cart:", error);
// Handle errors gracefully
return { success: false, message: "Failed to add item to cart" };
}
}
// app/components/AddToCartButton.tsx
"use client";
import { useState } from "react";
import { addItemToCart } from "@/app/actions";
import { useSession } from "next-auth/react";
export default function AddToCartButton({ productId }: { productId: string }) {
const { data: session } = useSession();
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const handleClick = async () => {
setLoading(true);
setMessage("");
// Call the Server Action, passing data and handling the result
const result = await addItemToCart(session?.user?.id, productId, 1);
setLoading(false);
if (result.success) {
setMessage(result.message);
// or other side effects
} else {
setMessage("Error adding item to cart");
}
};
return (
<div>
<button onClick={handleClick} disabled={loading}>
{loading ? "Adding..." : "Add to Cart"}
</button>
{message && <p>{message}</p>}
</div>
);
}
This example demonstrates a few key best practices:
Input Validation: The Server Action validates the input to prevent errors and security vulnerabilities.
Error Handling: The
try...catch
block ensures that errors are handled gracefully and informative messages are returned to the client.Database Interaction: We use a hypothetical database client (
db
) to interact with the database. In a real app, you'd likely use an ORM like Prisma.Cache Revalidation: We use
revalidatePath
to keep the product and cart pages up-to-date.UI Logic Separation: The
AddToCartButton
component handles the UI and user interactions, keeping the Server Action focused on data and server-side logic.
This streamlined example showcases how Server Actions can simplify common e-commerce tasks while adhering to essential best practices. Remember to modularize your actions, keep UI logic separate, and always validate user inputs. While this provides a good starting point, more complex scenarios might require more sophisticated error handling, caching strategies, and database interactions.
When To Use React Query With Next.js Server Components
React Server Components have revolutionized how we think about data fetching in React applications. But what happens when you want to use React Query alongside server components? Should we always combine them? The answer might surprise you.
With React now running on both client and server, developers are grappling with how traditional client-side libraries like React Query fit into this new paradigm. The reality is more nuanced than simply “use both everywhere“.
Setting up React Query for Server Components
The foundation of using React Query with server components lies in proper setup. Here’s a key pattern:
The Query Client Factory
import { isServer, QueryClient } from "@tanstack/react-query";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (isServer) {
// Always create a new query client on the server
return makeQueryClient();
} else {
// Create a singleton on the client
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}
}
Why This Pattern Matters
The server-client distinction is crucial:
Server: Always create a new query client instance for each request to avoid data leakage between users.
Client: Maintain a singleton to persist across component re-renders and suspend boundaries.
This pattern is especially important in Next.js, where the layout component is wrapped in a Suspense boundary behind the scenes. Without the singleton pattern, you would lose your client instance every time a component suspends.
The Server-Client Data Flow
Here’s how data flows from server to client:
1. Server Component (Prefetching)
// posts/page.tsx - Server Component
export default async function PostsPage() {
const queryClient = getQueryClient();
// Prefetch data on the server
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostsClient />
</HydrationBoundary>
);
}
2. Client Component (Consumption)
// PostsClient.tsx - Client Component
'use client';
export default function PostsClient() {
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
});
return (
<div>
{posts?.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}
3. The Hydration Bridge
The HydrationBoundary
component bridges the server and client by:
Dehydrating the query client state on the server
Rehydrating it on the client
Making prefetched data immediately available
This Is Actually Good!
Data is prefetched on the server
Hydrated to the client without additional network requests
The user sees data immediately without loading states
This is exactly what makes React Query with Next.js so powerful — you get server-side rendering with client-side cache management, giving you the best of both worlds for performance and user experience.
What Not To Do
A common mistake is fetching data in server components and trying to use it directly:
// ❌ Don't do this
export default async function PostsPage() {
const queryClient = getQueryClient();
const posts = await queryClient.fetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
});
return <PostsClient posts={posts} />;
}
Why this breaks: Server components don’t re-render. If the client-side query cache gets invalidated or updated, your server component will display stale data, creating UI inconsistencies.
Why doesn’t the client refresh data on the first load?
When using React Query with Next.js Server Components, something important happens behind the scenes during the initial page load.
First, your server component fetches the data ahead of time using React Query’s prefetchQuery. This means the server already has the data ready before sending the page to the browser.
Then, using the <HydrationBoundary>, this prefetched data is passed down to the client, so React Query on the client side starts with a fully populated cache.
Because the data is already available and considered fresh, React Query doesn’t make a new network request when the page loads in the browser. It simply reads from the cache. This improves performance and avoids unnecessary data fetching.
However, if you change a filter or the query becomes stale, React Query will then fetch new data as needed.
This setup allows you to:
Render data instantly on first load
Avoid duplicate fetching
Keep the client-side declarative (using useQuery)
Maintain a clean separation between server and client responsibilities
In short, the server does the heavy lifting up front, and the client reuses that work efficiently.
When to Use React Query with Server Components
React Query with server components makes sense when you need:
1. Client-Specific Features
Infinite Queries for pagination and infinite scroll:
// Server prefetch
await queryClient.prefetchInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => getPosts(pageParam),
});
// Client usage
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => getPosts(pageParam),
getNextPageParam: (lastPage) => lastPage.nextPage,
});
2. Real-time Updates
When you need optimistic updates, cache invalidation, or real-time synchronization across components.
3. Complex State Management
For applications requiring sophisticated caching strategies, background refetching, or retry logic.
**When to Skip React Query
**Often, you’re better off with pure server components:
// Simple and effective
export default async function PostsPage() {
const posts = await getPosts();
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
This approach offers:
Better performance: No JavaScript bundle for data fetching
Simpler architecture: Fewer moving parts
Better SEO: Content rendered on the server
Faster initial load: No client-side fetching delay
Making the Right Choice
Consider these questions:
Do you need client-side interactivity like infinite scroll, real-time updates, or optimistic mutations?
Is your data relatively static or does it change frequently?
Do you need complex caching strategies or is simple server-side fetching sufficient?
Are you building a highly interactive app or primarily displaying content?
Best Practices
1. Start Simple
Begin with pure server components. Add React Query only when you need client-specific features.
2. Use Appropriate Stale Times
Set longer stale times when prefetching to avoid immediate refetches:
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Prevent immediate refetch after prefetch
},
}
3. Separate Concerns
Keep your client components unaware of server prefetching. They should work independently.
4. Consider Bundle Size
React Query adds to your JavaScript bundle. Ensure the benefits outweigh the costs.
Real-world use cases
Example 1: Basic Query — Filter-based query caching
This example demonstrates React Query’s fundamental capabilities with Next.js App Router. It showcases:
Server-side prefetching that hydrates the client cache on initial load
Filter-based query keys that maintain separate cache entries per filter
Automatic cache invalidation after the configured stale time (60 seconds)
Clean separation between server and client components
The UI displays a collection of shoes that can be filtered by category. When users switch filters, you can observe React Query’s intelligent caching, only fetching new data when needed, while serving cached data instantly.
Example 2: Infinite Query — Infinite scroll with pagination
This example showcases React Query’s advanced infinite scrolling capabilities. Key features include:
Implementation of useInfiniteQuery for paginated data loading
Automatic loading of the next pages as the user scrolls to the bottom
Server-side prefetching of the initial data page with prefetchInfiniteQuery
Cursor-based pagination handling a dataset of 100 items in batches of 10
Intersection Observer integration for detecting when to load more data
The UI demonstrates a real-world infinite scroll implementation with loading indicators and smooth state transitions, all while maintaining the benefits of React Query’s caching system.
These examples together provide a comprehensive look at how React Query integrates with Next.js to solve common data fetching challenges.
When Should I Use Server Action (Next.js 14)
I started experimenting with the new Next Server Actions, I quickly learned to love it. They felt like a clean and efficient solution for most of my server requests.
But as I started to play with it and implement it more, I started noticing I was introducing performance issues, unnecessary roundtrips between the client and the server, and even some dangerous API security issues.
I quickly learned that Server Actions weren’t always the most efficient choice, especially for straightforward data fetching.
So, let’s dive in. In the next few paragraphs, I will explain the different use cases for Server Actions and show you how they can be a game-changer in your Next.js app, especially when used in the right context.
What are Server Actions
Server Actions (soon to be named Server Functions in React 19) are a relatively new feature introduced by React to simplify data handling and move more logic to the server side. Next.js has quickly incorporated its own twist in Next.js 14, offering good complementarity with Server-Side Components.
Server Actions were introduced to simplify server-side mutations (e.g, form submissions or database writes) in the Next.js App Router. They enable an RPC-like mechanism. When you call a Server Action from a client component, Next.js serializes the call and sends a POST
request under the hood to execute that function on the server, then returns the result to your app. This is powerful for things like creating a new record or processing a form without setting up a separate API route. After the action runs, Next.js can automatically re-render affected UI parts for you.
Crucially, though, Server Actions were designed for mutations, not queries. The React team explicitly notes that Server Actions are designed for mutations that update server-side state; they are not recommended for data fetching. In fact, Next.js documentation reiterates that data fetching should primarily happen in Server Components, whereas Server Actions are not intended for data fetching but for mutations. Using them purely to read data breaks the intended separation of concerns.
In the following lines, we will focus only on the Next.js implementation:
When to use Server Actions
Event handling / Performing Database Mutations
Server actions allow you to perform server operations and database mutations securely without exposing database logic or credentials to the client. They drastically reduce and simplify your code because they remove the need to write a specific API route for your operations.
'use server'; export async function handleServerEvent(eventData: any) { // Process any server event const res = await someAsyncOperation(eventData); if (!res.ok) { throw new Error('Failed to handle event'); } return { res, message: 'Server event handled successfully' }; }
Handling form submissions
Similar to the first point, Server Actions are particularly useful for processing form inputs that need server-side handling. They provide a straightforward way to handle form data on the server, ensuring data validation and integrity without exposing the logic to the client and without having to implement elaborate API endpoints.
'use server'; export async function handleFormSubmit(formData: FormData) { const name = formData.get('name') as string; const email = formData.get('email') as string; const message = formData.get('message') as string; // Process the form data const res = await saveToDatabase({ name, email, message }); if (!res.ok) { throw new Error('Failed to process the form data'); } return { res, message: 'Form submitted successfully' }; }
Fetching data from client components
Server Actions can also be useful for quick data fetching, where a clean developer experience (DX) is crucial. It can simplify the fetch process by typing data access directly to a component without the need for intermediary API layers. Moreover, when using Typescript, Server Actions make using types seamless because everything is within the same function boundary.
// Simple server action to fetch data from an API 'use server'; export async function fetchData() { const res = await fetch('https://api.example.com/data'); if (!res.ok) { throw new Error('Failed to fetch data'); } return res.json(); }
Working with Next.js and its server component, you already have this very practical way of using server-side code to fetch data and pre-render the page on the server.
But Server Action now introduces a brand new way to also do that from your client-side components! It can simplify the fetch process by typing data access directly to the component that needs it, without the need to use
useEffects
hooks or client-side data fetching libraries.Moreover, when using TypeScript, Server Actions make typing seamless because everything is within the same function boundary, providing a great developer experience overall.
Potential Pitfalls with Server Actions
Don’t use server actions from your server-side components
The simplicity and great DX of Server Actions could make it tempting to use them everywhere, including from a server-side component, and it would work! However, it doesn't really make any sense. Indeed, since your code is already running on the server, you already have the means to fetch anything you need and provide it as props to your page. Using Server Actions here would delay data availability as it causes extra network requests.
For client-side fetching, Server Actions might also not be the best option. First of all, they always automatically use POST requests, and they can not be cached automatically like a GET request. Secondly, if your app needs advanced client-side caching and state management, using tools like TanStack Query (React Query) or SWR is going to be way more effective for that. However, I haven’t tested it myself yet, but it’s apparently possible to combine both and use TanStack Query to call your server actions directly.
Server Actions Do Not Hide Requests
Be extremely careful when using server actions for sensitive data. Server Actions do not hide or secure your API requests. Even though Server Actions handle server-side logic, under the hood, they are just another API route, and POST requests are handled automatically by Next.js.
Anyone can replicate them by using a Rest Client, making it essential to validate each request and authenticate users appropriately. If there is sensitive logic involved, ensure you have
proper authentication and authorization checks
within your Server Actions.Note: Additionally, consider using the very popular next-safe-actions package, which can help secure your actions and also provide type safety.
Every Action Adds Server Load
Using Server Actions might feel convenient, but every action comes at a cost. The more you offload onto the server, the greater the demand for server resources. You may inadvertently increase your app’s latency and cloud cost by using Server Actions when client-side processing would suffice. Lightweight operations that could easily run on the client, like formatting dates, sorting data, or managing small UI state transitions, should stay on the client side to keep your server load minimal.
Classic API Routes Might Be More Appropriate
There are cases when sticking with traditional API routes makes more sense, particularly when you need your API to be accessible to multiple clients. Imagine if you need the same logic for both your web app and a mobile app, duplicating the same Server Action logic into an API route will only double the work and maintenance. In these situations, having a centralized API route that all clients can call is a better solution, as it avoids redundancy and ensures consistency across your different clients.
Next.js Dependency and the Moving Target
It’s important to note that Server Actions are closely integrated with Next.js, and both Next.js and React are evolving rapidly. This pace of development can introduce compatibility issues or breaking changes as these frameworks continue to update. If your application prioritizes stability and long-term support, relying heavily on cutting-edge features like Server Actions could result in unwanted technical debt. Weighing the stability of traditional, well-established methods against the appeal of new features is always advisable.
How I Made a Next.js App Load 10x Faster
Ditch Useless useEffect
Calls 🚫
Let’s talk about useEffect()
— The go-to hammer for devs who see every problem as a nail.
I had components using useEffect()
for things that could be done statically or with server-side rendering.
Removing unnecessary useEffect
calls instantly reduced my client-side JavaScript loads.
The Wrong Way
This forces the client to fetch the data after the page loads. Slow and unnecessary.
The Right Way (Server Components FTW)
No useEffect()
, no state management, just pure server-side rendering (SSR). The page loads with data pre-fetched.
Use Image Optimization (next/image
) 📷
If you are still using <img>
tags like it’s 2010, you are missing out.
I had multiple large images slowing down my page.
Switching to Next.js’s next/image
reduced my Largest Contentful Paint (LCP).
The Old Way (Bad Performance)
This loads the full image, even if the user is on a small screen. Wasteful
The Next.js Way
This automatically:
Optimizes image size
Serves WebP format when possible
Lazy-loads non-critical images
One area of confusion is how to handle images in Next.js. The process differs depending on whether you are working with local or remote images.
For local images, you don’t need to specify a width and height. Next.js will automatically identify the dimensions. Simply import the image and render it using the next/img
component.
import Image from "next/image";
import localImage from "public/hoy.png";
export default function MyComponent() {
return <Image src={localImage} alt="Local Image" />;
}
For remote images, you need to provide a blur placeholder and specify the width and height to prevent layout shifts. You can use the placeholder=blur
prop to show a blurred version of the image until the full image loads.
To generate the blur data URL for remote images, you can use the sharp
and placeholder
packages:
import Image from "next/image";
import getBase64 from "./utils/getBase64";
export default async function MyComponent() {
const blurDataUrl = await getBase64(remoteImageUrl);
return (
<Image
src={remoteImageUrl}
width={600}
height={600}
alt="Remote Image"
placeholder="blur"
blurDataURL={blurDataUrl}
/>
);
}
The getBase64
utility function fetches the remote image, converts it to an ArrayBuffer, and then generates the base64 representation using the placeholder
package.
Reduce JavaScript (Move Logic to the Server)
Every extra KB of JavaScript adds execution time on the client.
The tricks.
Offload work to the backend or Edge functions.
I used to process data on the client, which was dumb because the same logic could run on the server and send the response.
Bad: Processing on the Client
This means fetching everything first and then filtering on the client. Unnecessary load.
Good: Processing on the Server
Now, only relevant data reaches the client.
Less JavaScript = faster rendering
Use Static Generation When Possible (getStaticProps
)
If your data doesn't change often, don’t fetch it dynamically.
Instead, pre-generate pages at build time using getStaticProps()
.
export async function getStaticProps() {
const res = await fetch("https://api.example.com/posts");
const posts = await res.json();
return { props: { posts }, revalidate: 60 }; // Refresh every 60s
}
export default function Blog({ posts }) {
return <div>{posts.map((p) => <p key={p.id}>{p.title}</p>)}</div>;
}
This generates HTML ahead of time, meaning zero wait time when users visit the page.
Optimize Fonts & Reduce CLS (Cumulative Layout Shift)
Google hates layout shifts.
If your font loads late, users will see a “flash of invisible text“ (FOIT).
I fixed this by:
Using system fonts (
sans-serif
,serif
)Preloading custom fonts
Setting
font-display: swap
This prevents text from “jumping“ when fonts load. Smooth experience.
Hybrid Rendering in Next.js: Mixing SSG, SSR, and ISR for Ultimate Flexibility
Next.js makes it easy to combine different rendering strategies in the same project. This flexibility helps you get the best performance and user experience for each type of content in your app.
- Static Site Generation (SSG)
It’s best for pages with content that is rarely changing (e.g., About, Contact)
// app/about/page.tsx
export default function About() {
return (
<main>
<h1>About Us</h1>
<p>We are a company focused on innovation and customer service.</p>
</main>
);
}
The HTML for this page is generated at build time and served quickly from a CDN. Great for SEO and speed.
- Incremental Static Regeneration (ISR)
It’s best for pages that update very often (e.g., blog posts, product pages).
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // Regenerate every hour
async function getPost(slug) {
const res = await fetch(`https://api.example.com/posts/${slug}`);
return res.json();
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
This page is generated at build time, but Next.js will automatically update it in the background every hour. Users always see the fast page, but the content stays fresh.
- Server-Side Rendering (SSR)
It’s best for pages with personalized or real-time data (e.g., user dashboards).
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // Forces SSR
async function getUserData() {
const res = await fetch('https://api.example.com/user/dashboard', {
cache: 'no-store' // Always get fresh data
});
return res.json();
}
export default async function Dashboard() {
const user = await getUserData();
return (
<main>
<h1>Welcome, {user.name}!</h1>
<p>Your last login: {new Date(user.lastLogin).toLocaleString()}</p>
</main>
);
}
This page is generated on the server every time users visit, so the data is always up to date.
You can use different strategies for different routes.
app/
├─ about/ # SSG
├─ blog/ # ISR
│ └─ [slug]/
├─ products/ # ISR
│ └─ [id]/
└─ dashboard/ # SSR
unstable_cache in Action: Smarter, Faster Data Fetching
unstable_cache
allows you to cache the result of expensive operations (like database queries) and reuse them across requests, boosting performance.
unstable_cache receives 3 parameters:
const cachedFn = unstable_cache(fetchData, keyParts, options)
fetchData: An async function returning the data to cache
kerParts: An array that uniquely identifies the cache query (especially useful when using external variables)
options: Advanced config:
tags
: for selective invalidation,revalidate
: time in seconds for automatic revalidation.
import { unstable_cache } from 'next/cache';
const getProduct = unstable_cache(
async (id: string) => db.products.findUnique({ where: { id } }),
['product-details'],
{ tags: ['products'], revalidate: 3600 }
);
Revalidations Strategy:
- Time-based (TTL):
{ revalidate: 60 } // Revalidates every minute
2. Manual invalidation
- Use
revalidateTag
with the tags you defined:
import { revalidateTag } from 'next/cache';
revalidateTag('products'); // Invalidates all caches with the 'products' tag
3. Path-based invalidation:
revalidatePath('/products/[id]');
Caching with Dynamic Variables
const getUserData = unstable_cache(
async (userId) => {
const session = await auth();
return db.users.find({ userId, org: session.org });
},
[userId, 'user-profile'], // Include variables in the cache key
{ tags: [`user-${userId}`] }
);
Usage in Server Components
export default async function Profile({ params }) {
const user = await getCachedUser(params.id);
return <div>{user.name}</div>;
}
Full Example with Invalidation
// app/api/products/route.ts
import { unstable_cache, revalidateTag } from 'next/cache';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const productId = searchParams.get('id');
const product = await unstable_cache(
() => db.products.findUnique({ where: { id: productId } }),
['product', productId],
{ tags: [`product-${productId}`] }
)();
return Response.json(product);
}
export async function POST(request: Request) {
const data = await request.json();
await db.products.update({ where: { id: data.id }, data });
revalidateTag(`product-${data.id}`);
return Response.json({ success: true });
}
Analyze your bundle size with @next/bundle-analyzer
Bundle size directly affects your app’s performance. If your JavaScript bundle is too large, your app will load more slowly, especially on mobile networks. To analyze it next/bundle-analyzer.
Usage with environment variables:
Create a next.config.js (and make sure you have next-bundle-analyzer
set up)
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({})
Then you can run the command below:
# Analyze is done on build when env var is set
ANALYZE=true yarn build
When enabled**,** three HTML files (client.html, edge.html, and nodejs.html) will be outputted to <distDir>/analyze/
. One will be for the Node.js server bundle, one for the edge server bundle, and one for the browser bundle.
Use Dynamic Imports to Load Components Lazily
Some components are not needed during the initial page load. These should be loaded only when required using next/dynamic
import dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('../components/HeavyComponent'), {
loading: () => <p>Loading...</p>,
ssr: false,
});
Loading components only when needed helps your app load faster. It avoids downloading or executing heavy JavaScript files on the first visit. This leads to better performance and also improves Core Web Vitals scores.
Use Middleware for Specific Routes Only
Middleware runs before a request hits your route handler. It can add latency, so limit its use. Avoid running middleware on all pages unless absolutely necessary. This improves response times and reduces unnecessary processing.
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};
Tree Shake Your Code
Tree shaking is a way to eliminate unused code from your final JavaScript bundle. This reduces the amount of code the browser has to load, parse, and execute. To benefit from tree shaking, make sure you are using ES modules. These allow bundlers like Webpack (used by Next.js) to detect and remove code that is never used in your project.
Avoid using import * as
syntax because it can bring in more code than necessary. Instead, import only the specific functions or components you need. For example, instead of importing the whole lodash library, use:
import debounce from 'lodash/debounce';
This ensures only the debounce
function is included in your final bundle, not the entire lodash library. The result is a leaner and faster-loading application.
Set proper HTTP Headers for Caching
Caching is essential for improving performance and reducing server load. When a browser caches static assets (like JavaScript, CSS, and images), it can reuse those files instead of downloading them again on every visit. This significantly speeds up page loads for returning users.
For example, by setting a Cache-Control
header ike public, max-age=31536000, immutable
, you tell the browser to cache the file for one year and not check for updates. This works well for assets that don’t change frequently, such as fonts, logos, or versioned build files.
You can configure caching headers in next.config.js
using the headers()
async function. This ensures consistent behavior across all your static files and can boost your app’s performance, especially on repeat visits.
module.exports = {
async headers() {
return [
{
source: '/:all*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
],
},
];
},
};
Use Edge Functions or Low Latency
Edge functions allow you to run server-side code at locations geographically closer to the user. This reduces latency and improves performance for time-sensitive operations like authentication, geolocation, personalization, and A/B testing.
Instead of sending every request to a centralized server, edge functions respond from CDN-like locations, which can drastically lower response times, especially for global audiences. For example, an edge function can validate a token, set a cookie, or redirect a user without needing to hit your origin server.
In Next.js, you can enable edge functions in next.config.js:
module.exports = {
experimental: {
runtime: 'edge',
},
};
Use edge functions selectively, where speed matters most. They are ideal for logic that doesn’t require heavy backend computation or database access, ensuring a balance between performance and resource usage.
Clean Up Third-Party Scripts
Problem:
I was loading too many third-party libraries: analytics, fonts, widgets, you name it.
Each one added more scripts and blocked the main thread.
What I Did:
Removed unused libraries (like that chat widget no one used).
Loaded non-critical scripts after interaction using
next/script
.
import Script from 'next/script';
<Script
src="https://analytics.example.com/script.js"
strategy="afterInteractive"
/>
- Used
preconnect
anddns-prefetch
for external resources.
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
Result: Time to Interactive (TTI) improved by 2.5 seconds.
Conclusion
In this article, we have covered several advanced Next.js concepts. By mastering these concepts, you will be able to build powerful, performant web applications with Next.js. Whether you are building a small blog or a large-scale e-commerce platform, Next.js has the tools and features you need to deliver a seamless user experience.
References
https://www.freecodecamp.org/news/nextjs-vs-react-differences/
https://javascript.plainenglish.io/next-js-client-side-rendering-56a3cae65148
https://blog.devgenius.io/advanced-next-js-concepts-8439a8752597
https://blog.stackademic.com/you-must-use-middleware-like-this-in-next-js-64d59bb4cd59
https://yohanlb.hashnode.dev/when-should-i-use-server-action-nextjs-14?ref=dailydev
https://blog.devgenius.io/10-powerful-next-js-optimization-tips-f78288d284e1
https://javascript.plainenglish.io/next-js-hates-me-until-i-made-it-10x-faster-cae6d1b65876
https://medium.com/yopeso/a-year-with-next-js-server-actions-lessons-learned-93ef7b518c73
https://medium.com/gitconnected/when-to-use-react-query-with-next-js-server-components-f5d10193cd0a
Subscribe to my newsletter
Read articles from Tuan Tran Van directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Tuan Tran Van
Tuan Tran Van
I am a developer creating open-source projects and writing about web development, side projects, and productivity.