The Next.js App Router has introduced a range of useful features, such as support for React Server Components, better caching, streaming responses, and nested layouts. These features let developers create an improved user experience while also enjoying a better developer experience. While these features are helpful and user-friendly, if used incorrectly, they can introduce bugs or performance issues.
In this article, we'll explore the most common mistakes you can make with the Next.js App Router. We'll also present solutions, with code examples, to help mitigate these mistakes and enhance the user experience.
Note that the examples in this article use the Next.js /src/app
project structure.
Making redundant network requests within components
Fetching data on the client side has been a common practice in React for many years. So it's easy to fall into the trap of making redundant network requests on the client component, which is not as efficient because of the added network latency and you being required to create different HTTP route handlers.
In the following example, the <UserComponent>
fetches data on the client side using the useEffect
hook. While this approach works, it's not ideal because it adds more network requests on the client side and can lead to a slower user experience.
"use client";
import { useEffect, useState } from "react";
const UserComponent = () => {
const [data, setData] = useState<{ name: string; email: string } | null>(
null
);
useEffect(() => {
const fetchData = async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
const result = await res.json();
setData(result);
};
fetchData();
}, []);
if (!data) return <div>Loading...</div>;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
};
export default UserComponent;
To fix this, you can create a server component that fetches data on the server side and renders it in the UserComponentFixed
component on the server:
const fetchUser = async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
if (!res.ok) throw new Error("Failed to fetch user");
return res.json();
};
const UserComponentFixed = async () => {
const data = await fetchUser(); // Fetching on the server side
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
};
export default UserComponentFixed;
This ensures that the client receives an HTML markup, which reduces the load on the client to fetch and render the data, hence improving the overall user experience.
Rendering dynamic routes as static by mistake
The Next.js App Router implements a static-first approach, which means that pages are always rendered as static unless they explicitly use dynamic APIs. While this approach improves the performance of the application, it can cause issues by misidentifying the page as static when you are not using dynamic APIs but want to render the page dynamically.
In the following example, the Home
server component fetches random user data on every request and displays it on the page:
// src/app/page.tsx
export function random(min: number, max: number) {
return Math.floor(Math.random() * (max - min) + min);
}
export const fetchUser = async (id: number) => {
const res = await fetch("https://jsonplaceholder.typicode.com/users/" + id);
if (!res.ok) throw new Error("Failed to fetch user");
const data = await res.json();
return { ...data };
};
export default async function Home() {
const userId = random(1, 10);
const data = await fetchUser(userId);
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
During development, the Home
component generates a new random number on each render. But in the production build created after running next build
, the page is statically generated, so it consistently displays the details for the same user. This is not the expected behavior.
To fix this issue, you can use the connection function to explicitly render the page as a dynamic page. The page will then render random user data on each reload:
// src/app/page.tsx
import { connection } from "next/server";
export function random(min: number, max: number) {
return Math.floor(Math.random() * (max - min) + min);
}
export const fetchUser = async (id: number) => {
const res = await fetch("https://jsonplaceholder.typicode.com/users/" + id);
if (!res.ok) throw new Error("Failed to fetch user");
const data = await res.json();
return { ...data };
};
export default async function Home() {
await connection();
const userId = random(1, 10);
const data = await fetchUser(userId);
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
Attempting to use server-specific actions within client components
Server actions let you execute functions on the server from the server and client components for use cases such as form submission and data mutations.
While you can create a server action directly inside a server component, you cannot do the same for client components. To safely define and use server actions inside client components, you must define server actions in a separate file and import them into the client components. By doing this, you ensure that the server action is executed only on the server and the code is excluded from the client-side bundle.
If you are creating a to-do item client component, it makes sense to create a separate file for the server action and import it into the client component, as shown in the following example:
"use server";
// src/app/actions.ts
export async function completeTodo(id: string) {
// server action to perform todo completion
}
"use client";
// src/components/TodoItem.tsx
import { completeTodo } from "@/app/actions";
export function TodoItem() {
const id = "todo_23";
return <button onClick={() => completeTodo(id)}>Complete</button>;
}
Placing Suspense boundaries incorrectly, leading to poor loading experiences
React Suspense lets you display a loading state when waiting for a response. But you also need to know where to place the suspense boundaries to ensure that it improves the application's performance and provides a good user experience.
The following example sets a suspense boundary on the complete page:
// app/page.tsx
import { Suspense } from "react";
import UserProfile from "./components/UserProfile";
import RecentPosts from "./components/RecentPosts";
export default function Page() {
return (
<Suspense fallback={<div>Loading page...</div>}>
<UserProfile />{" "}
{/* Both components are delayed if one is still loading */}
<RecentPosts />
</Suspense>
);
}
This blocks the rendering until every component in the page has finished loading. It can also lead to slow loading times and negatively impact the application's performance.
It makes more sense to place the suspense boundary on each component that loads data:
// app/page.tsx
import { Suspense } from "react";
import UserProfile from "./components/UserProfile";
import RecentPosts from "./components/RecentPosts";
export default function Page() {
return (
<div>
<h1>Welcome to the Dashboard</h1>
{/* Place Suspense boundaries around each section */}
<Suspense fallback={<div>Loading profile...</div>}>
<UserProfile /> {/* Shows loading state only for this component */}
</Suspense>
<Suspense fallback={<div>Loading recent posts...</div>}>
<RecentPosts /> {/* Shows a different loading state for posts */}
</Suspense>
</div>
);
}
This way, the user can see the dashboard page with loading skeletons and the components will render as they load without blocking each other.
Using client-side hooks to access request data, leading to errors
The App Router provides utility functions to access request data, such as headers and cookies, directly in the route handlers and the server components. But you can't use these functions in the client components because they are server-side utilities. Instead, you can use the usePathname
, useSearchParams
, and useParams
hooks in the client components to access information about the current route.
If you need to access the request headers or cookies in the client component, you can access them in a server component and pass the derived value to the client component. In the following example, the UserProfile
client component needs to access the logged-in user's name, which can only be retrieved from the secured cookie:
// src/components/UserProfile.tsx
"use client"; // Marked as a client component
type UserProfileProps = {
userName: string;
};
export default function UserProfile({ userName }: UserProfileProps) {
// some interactive logic for the client component
return (
<div>
<h2>User Information</h2>
<p>Logged in as: {userName}</p>
</div>
);
}
To solve this, you can create a server component that retrieves the user's name from the cookie and passes it to the client component:
// src/app/page.tsx
import { cookies } from "next/headers";
import { UserProfile } from "./components/UserProfile";
export default async function Page() {
const cookieStore = await cookies();
const session = cookieStore.get("session");
const user = await getUserFromSession(session); // this is a custom function
return (
<div>
<UserProfile userName={user.name} />
</div>
);
}
Placing context providers in server components where they aren't supported
React Context is a client-only API and is not supported in the server components. So using context directly in the root layout would not work, as shown in the following example:
// src/app/layout.tsx
import { createContext } from "react";
// createContext is not supported in Server Components
export const ThemeContext = createContext({});
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
</body>
</html>
);
}
To fix this issue, you can create a client component that exports the context and uses it in the server component:
// src/components/ThemeProvider.tsx
"use client";
import { createContext } from "react";
export const ThemeContext = createContext({});
export default function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}
// src/app/layout.tsx
import ThemeProvider from "./components/ThemeProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
It's better to use the context providers as deep into the application tree as possible to let Next.js optimize as much as possible. So if the ThemeProvider
is only used in the /dashboard/settings
route, render it in the nearest layout.tsx
file for the best performance.
Inappropriately mixing server and client components, causing functionality issues
Server and client components work in tandem, but each has a specific functionality. Use client components for any state management and interactivity. You can use React hooks inside a client component but not inside a server component, as in the following example:
// src/components/CheckoutForm.tsx
"use client";
import { useState, useEffect } from "react";
export default function CheckoutForm() {
const [name, setName] = useState("");
useEffect(() => {
// some effect logic
}, []);
return <form>{/* interactive form */}</form>;
}
Server components are best suited for data fetching because of the added security and performance benefits. They're also preferred when you need heavy libraries for data manipulation, such as data-management or data-parsing libraries. Here's an example:
// src/components/UserDetails.tsx
import { fetchUser } from "./UserAvatar";
const UserDetailsComponent = async () => {
const data = await fetchUser(5);
return (
<div>
No store:
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
};
export default UserDetailsComponent;
The following example demonstrates the usage of client and server components on the same page:
// src/app/page.tsx
import UserDetails from "./components/UserDetails";
export default function Page() {
// The Page function is a server component
return (
<div>
<h1>Dashboard</h1>
{/* Including a client component to handle dynamic state */}
<CheckoutForm />
<UserDetails />
</div>
);
}
Errors in dynamic routing, particularly migrating from getStaticPaths and getServerSideProps
The latest Next.js version also supports the legacy Pages Router directory structure to provide a smooth transition for existing projects that want to gradually adopt the App Router features.
The getServerSideProps
function is used to fetch the data on the server side and pass the result to the page component. So, when you're migrating, it can be tempting to convert the function into a route handler and consume it using a fetch
API call. But by adding React Server Components, you can place the server-side data-fetching code directly inside a server component.
In the following example, the getServerSideProps
function is used to fetch data on the server side and pass it to the Orders
page component, and the Orders
component is then used to render the data on the client side:
// pages/orders.js (Pages Router)
import React from "react";
export default function Orders({ orders }) {
return (
<div>
<h1>Your Orders</h1>
<ul>
{orders.map((order) => (
<li key={order.id}>
<p>
Order #{order.id}: {order.item}
</p>
</li>
))}
</ul>
</div>
);
}
// Fetch data for the page with getServerSideProps
export async function getServerSideProps() {
const res = await fetch("https://api.example.com/orders");
const orders = await res.json();
return {
props: {
orders,
},
};
}
In the App Router, you can fetch the orders data directly in the OrdersPage
server component and render it on the server side:
// src/app/orders/page.tsx (App Router, Server Component)
export default async function OrdersPage() {
// Fetch the data directly in the server component
const res = await fetch("https://api.example.com/orders");
const orders = await res.json();
return (
<div>
<h1>Your Orders</h1>
<ul>
{orders.map((order) => (
<li key={order.id}>
<p>
Order #{order.id}: {order.item}
</p>
</li>
))}
</ul>
</div>
);
}
Similarly, the getStaticPaths
function in the Pages Router is used to generate a list of paths for a statically rendered dynamic route. In the App Router, you use the generateStaticParams
function to generate an array of params objects for dynamic routes, as follows:
import PostLayout from "./components/post-layout";
export async function generateStaticParams() {
return [{ id: "1" }, { id: "2" }];
}
async function getPost(params) {
const res = await fetch(`https://api.example.com/posts/${params.id}`);
const post = await res.json();
return post;
}
export default async function Post({ params }) {
const post = await getPost(params);
return <PostLayout post={post} />;
}
Errors due to incorrect module imports or server-side code in client components
While using client components inside server components offers significant opportunities for composition, it can also blur the boundaries between client and server code at times. This blurring creates a risk of unintentionally exposing sensitive server-side code or secrets to the client by mistakenly including them in the client component.
To ensure this never happens, always use the use server
directive at the top of the server action function declarations. Better yet, make a separate actions.ts
file to contain all the server actions and use the use server
directive at the top or module level.
You can also use the server-only
npm package to declare a server-only module explicitly. For example, if you have a data-fetching module called src/data.ts
, you can install the server-only
module and use it as follows to exclude the code from the client-side bundle even if it's imported by mistake:
import "server-only";
export async function getUsers() {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
return res.json();
}
Issues with APIs and slug handling, often due to improper configuration
In the App Router, you can create API routes directly by creating a new route.ts
file at the path you want. From this file, you can export different HTTP methods, such as GET
, POST
, PUT
, and more.
For the GET
method route handles, you can configure caching by exporting the dynamic
route config option. For example, the following route will be cached and revalidated every sixty seconds:
export const dynamic = "force-static";
export const revalidate = 60;
export async function GET() {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
const data = await res.json();
return Response.json({ data });
}
One caveat when using force-static
is that the dynamic functions, such as headers
or cookies
, will return empty values, so you can't use them.
Most of the API routes will need one or more dynamic parameters to identify the resource being requested. You can use the slug
parameter to handle this. You can define slugs in the following ways:
app/users/[slug]/route.ts
. This format will match/users/1
,/users/2
, and so on.app/users/[...slug].ts
. This format will catch all dynamic segments, which means that this route will match/users/1
,/users/1/orders
,/users/1/orders/1
, and so on.app/users/[[...slug]].ts
. This is similar to a catch-all but will also match/users
because dynamic segments are optional.
You can access the slug
from the params
prop in layouts, pages, and routes. For instance, the src/app/blog/[slug]/page.tsx
page can access the slug
from the params
prop as follows:
// src/app/blog/[slug]/page.tsx
export default function Page({ params }: { params: { slug: string } }) {
return <div>My Post: {params.slug}</div>;
}
Similarly, you can access the slug
from the params
option in a GET
route handler, as follows:
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const slug = (await params).slug;
}
Conclusion
In this article, we covered some of the most common pitfalls when using the Next.js App Router and discussed ways to address these and enhance the overall user experience.
If you're developing with Next.js applications, you can deploy them on Upsun, which provides features such as vertical and horizontal scaling, edge caching, and observability out of the box.