Next.js is a popular React-based framework for building full-stack web applications. It has, for better or worse, a unique approach to handling routing. I like Next, but for me, it has far too many downsides to use for large enterprise applications, especially those that are client-heavy. And today, I will write about Next.js behaviors and how to deal with them.
First things first, let's set up a test environment to better understanding what we will deal with. For now, Iโll have one file for server actions where I fetch data, and just one home page component:
// src/actions/index.ts
"use server";
type Post = {
id: number;
userId: number;
title: string;
body: string;
}
export const getPost = async function (postId: number, delay: number = 1000): Promise<Post> {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`);
const json = await response.json();
await new Promise(resolve => setTimeout(resolve, delay));
return json;
}
type Todo = {
id: number;
userId: number;
title: string;
completed: boolean;
}
export const getTodo = async function (todoId: number, delay: number = 1000): Promise<Todo> {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${todoId}`);
const json = await response.json();
await new Promise(resolve => setTimeout(resolve, delay));
return json;
}
// src/app/page.tsx
import { getPost, getTodo } from '@/actions/index'
export default async function Home() {
const post = await getPost(1, 2000);
const todo = await getTodo(1, 5000);
return (
<main>
<section>{JSON.stringify(post)}</section>
<section>{JSON.stringify(todo)}</section>
</main>
);
}
Error Handling
Obviously, this code has two major problems: there's no way to display a loading state while the request is being resolved, and there's no error handling. For now, I'll postpone loading fallbacks and focus on error handling since it's not that difficult to fix. Next.js suggests a few ways to handle errors: redirecting to another page using redirect or notFound, returning the error as data by creating a wrapper that catches any error and returns some metadata about it, or using an error.tsx file.
I genuinely think that error.tsx
is the worst option among them all. Itโs not that it doesnโt have its usesโmy concern is that itโs an entire file that serves as a fallback UI for unexpected runtime errors across the whole page. Thatโs really problematic. If I have an error in getTodo
, I want to show an error only in the Todo component, not replace the entire page.
The redirect
and notFound
seem more attractive to use here, but they require pages to redirect the user to, and they still donโt handle runtime errors that can occur before the redirection happens. So, with all that said, weโre left with only one reliable way to handle errors: return an error as data.
Here is the basic wrapper for handling action errors:
// src/actions/index.ts
"use server";
type ActionData<T> = {
data: T;
error: null;
};
type ActionError = {
data: null;
error: Record<string, any>;
};
type Action<Args extends any[], Data> = (...args: Args) => Promise<Data>;
const createAction = function <Args extends any[], Data>(action: Action<Args, Data>) {
return async (...args: Parameters<Action<Args, Data>>): Promise<ActionData<Data> | ActionError> => {
return action(...args)
.then((data: Data) => ({ data, error: null }))
.catch((error: any) => ({ data: null, error: { message: error.message } }));
};
};
type Todo = {
id: number;
userId: number;
title: "string;"
completed: boolean;
};
export const getTodo = createAction(async function (todoId: number, delay: number = 1000): Promise<Todo> {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${todoId}`);
const json = await response.json();
await new Promise((resolve) => setTimeout(resolve, delay));
throw Error("Some error");
return json;
});
// etc...
Now, the Todo component is inside the page, and its related errors can be displayed without affecting other information on the page, such as the post info:
// src/app/page.tsx
import { getPost, getTodo } from "@/actions/index";
export default async function Home() {
const { data: postData, error: postError } = await getPost(1, 2000);
const { data: todoData, error: todoError } = await getTodo(1, 5000);
return (
<main>
<section>
{postData && <div className="text-green-400">{JSON.stringify(postData)}</div>}
{postError && <div className="text-red-400">{JSON.stringify(postError)}</div>}
</section>
<section>
{todoData && <div className="text-green-400">{JSON.stringify(todoData)}</div>}
{todoError && <div className="text-red-400">{JSON.stringify(todoError)}</div>}
</section>
</main>
);
}
With this setup, we successfully avoid creating multiple error.tsx
files for different routes (file overheat). However, you still need at least a root-level error.tsx
file to handle unexpected scenarios.
Itโs worth mentioning that you can still use custom React error boundaries. However, for this, data fetching needs to be placed in the target component where you plan to use the data. Of course, the error boundary component cannot be in the same file as your server component, since it is a client component. At this point, it's clear that I'm not a fan of creating that many files for a component's behavior when some logic can be grouped into a single file.
Loading Fallbacks
Displaying some UI for the user while data is being fetched is common sense, and yet, weโre currently not doing it. As with error handling, Next.js suggests a few ways to address thisโsuch as using a loading.tsx file or React Suspense. In Error Handling, I mentioned that we want to handle errors per component, not for the entire page. The same applies to loading fallbacks. If we use loading.tsx
, the loading fallback will be displayed (for the entire page) until the slowest promise is resolved. That is why loading.tsx
is not for us, and now we left with <Suspense>
.
To actually use suspense, we first need to move the fetching logic to a separate server component. Then, we wrap the component in <Suspense>
. Where exactly to wrap it is a separate question.
We can have both the loading component and the server component exported from the same file, and then use them with <Suspense>
at the page level:
// src/components/post.tsx
import { getPost } from "@/actions";
export function PostLoading() {
return <div>Post Loading....</div>;
}
export async function Post({ postId }: { postId: number }) {
const { data, error } = await getPost(postId, 2000);
return (
<section>
{data && <div className="text-green-400">{JSON.stringify(data)}</div>}
{error && <div className="text-red-400">{JSON.stringify(error)}</div>}
</section>
);
}
Or we can have everything in the same file, which is my preferred approach. If you're attentive, you might notice a small drawback: when I need to pass props to my <TodoServer>
component, I actually have to pass them through <Todo>
, which results in a bit of props drilling โ minor, but still... Iโll address this drilling later in the article.
// src/components/todo.tsx
import { Suspense } from "react";
import { getTodo } from "@/actions";
function TodoLoading() {
return <div>Todo Loading....</div>;
}
async function TodoServer({ todoId }: { todoId: number }) {
const { data, error } = await getTodo(todoId, 5000);
return (
<section>
{data && <div className="text-green-400">{JSON.stringify(data)}</div>}
{error && <div className="text-red-400">{JSON.stringify(error)}</div>}
</section>
);
}
export function Todo(props: { todoId: number }) {
return <Suspense fallback={<TodoLoading />} children={<TodoServer {...props} />} />;
}
That is just an example how will this look inside page component:
// src/app/page.tsx
import { Suspense } from "react";
import { Todo } from "@/components/todo";
import { Post, PostLoading } from "@/components/post";
export default async function Home() {
return (
<main>
<Suspense fallback={<PostLoading />}>
<Post postId={1} />
</Suspense>
<Todo todoId={1}/>
</main>
);
}
Now that weโve covered error handling and UI loading fallbacks, the application is much more interactive and user-friendly. Next, I want to address issues related to params
and searchParams
in Next.js.
Page Params Drilling
Next.js allows dynamic routing, so letโs move our Todo to a separate page with its own route. app/[todoId]/page.tsx
. At this stage, everything is fairly straightforward: define types for params
and searchParams
, await their resolution, display them, and pass them to the <Todo>
component:
// src/app/[todoId]/page.tsx
import { Todo } from "@/components/todo";
type PageProps = {
params: Promise<{ todoId: number }>;
searchParams: Promise<{ page: number }>;
};
export default async function TodoPage({ params, searchParams }: PageProps) {
const { todoId } = await params;
const { page } = await searchParams;
return (
<main>
<h2>TodoId: {todoId}</h2>
<h2>Page: {page}</h2>
<Todo todoId={todoId} />
</main>
);
}
At this point, you might feel satisfied โ but Iโm certainly not. You see, while this approach is good enough for a simple page DOM tree, I foresee a props drilling nightmare when it comes to a more complex setup.
What are you going to do if your component is nested inside another component, which is inside yet another one, and so on? Itโs not like you can use React Context in server components... This line of thinking leads to a justified desire to access the URL state directly within a server component.
In a server component, we can access cookies and headers, so I suggest utilizing them. However, before retrieving data from them, we need to store that data first โ and for this, Next.js middleware.ts is the best option:
// src/middleware.ts
import { NextResponse, NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
const { pathname, searchParams } = request.nextUrl;
response.cookies.set("x-page-params", JSON.stringify(pathname.split("/").filter(Boolean)));
response.cookies.set("x-page-search-params", JSON.stringify(Object.fromEntries(searchParams.entries())));
return response;
}
export const config = {
matcher: [
// Run on everything but Next internals and static files
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
],
};
As you can see, the params I put in the cookie are just an array taken from the pathname. This carries a bit of risk, because unlike Next.jsโwhich automatically maps params to your endpointโyou need to know exactly where your component is located to extract dynamic params, not just static ones.
The getters for these params are fairly simple. In them, I omitted checking whether the JSON was parsed correctly for the sake of simplicity, but I don't recommend skipping this check when building a real app:
// src/utils.ts
"use server";
import { cookies } from "next/headers";
export async function getParams(): Promise<Array<string>> {
const cookieStore = await cookies();
const pageParams = cookieStore.get("x-page-params");
return JSON.parse(pageParams ? pageParams.value : "[]") as Array<any>;
}
export async function getSearchParams(): Promise<Record<string, string>> {
const cookieStore = await cookies();
const pageParams = cookieStore.get("x-page-search-params");
return JSON.parse(pageParams ? pageParams.value : "{}") as Record<string, string>;
}
With getters in place, params and search params are available inside server components, and you may use them however you like:
// src/components/todo.tsx
import Link from "next/link";
import { Suspense } from 'react'
import { getTodo } from "@/actions";
import { getParams, getSearchParams } from "@/utils";
export function TodoLoading() {
return <div>Todo Loading....</div>;
}
export async function TodoServer() {
const params = await getParams();
const searchParams = await getSearchParams();
const todoId = +params[0] || 1;
const currentPage = +searchParams.page || 1;
const nextPageQuery = { ...searchParams, page: currentPage + 1 };
const prevPageQuery = { ...searchParams, page: currentPage - 1 };
const { data, error } = await getTodo(todoId, 2000);
return (
<section>
{data && (
<>
<div>{JSON.stringify(params)}</div>
<div>{JSON.stringify(data)}</div>
<div className="flex gap-8">
<Link href={{ query: prevPageQuery }}>Prev page</Link>
<span>Page: {currentPage}</span>
<Link href={{ query: nextPageQuery }}>Next page</Link>
</div>
</>
)}
{error && <div style={{ color: "red" }}>{JSON.stringify(error)}</div>}
</section>
);
}
export async function Todo() {
return <Suspense fallback={<TodoLoading />} children={<TodoServer />} />;
}
Try playing around with this pagination โ do you notice how React Suspense works in Next.js? That will be the next chapter to cover.
Suspense with Search Params
If you followed the code and tried to play around with it, you may notice that the loading fallback does not appear when the search parameter is changed via a link click.
Suspense fallback does not show when only search parameters change because Next.js does not remount the server component โ it reuses the existing one. Since no navigation to a new route occurs, the component isn't re-fetched, and Suspense is not triggered again. To force a reload, you must use dynamic key
or segment
that reacts to search params.
I prefer addressing this by creating a custom <Suspense>
that re-renders when the search params or path changeโsolving the issue where the fallback doesnโt show on search param updates.
// src/components/search-params-suspense.tsx
"use client";
import { Suspense } from "react";
import type { ComponentProps } from "react";
import { usePathname, useSearchParams } from "next/navigation";
export function SearchParamsSuspense(props: ComponentProps<typeof Suspense>) {
const pathname = usePathname();
const searchParams = useSearchParams();
const key = `${pathname}?${searchParams.toString()}`;
return <Suspense key={key} {...props} />;
}
That's pretty much all I wanted to share about working with Next.js. I won't claim that my approach is the only correct oneโsomeone might come up with a better solution. But for now, I'll just patiently wait for your thoughts on the matter. See ya!
Top comments (2)
P.S.
Next is using errors under the hood to handle logic of
redirect()
,unauthorized()
,notFound()
etc. That has a slight conflict withcreateAction
in its current state, so you won't be able to use it inside functions passed tocreateAction
. It's a pity, because you could fix it by throwing an error if the error in the action is not an instance of ActionErrorโthere's no generic NextError or similar. Personally, I avoid using these redirects directly inside server actions.Honestly, fuck all said above, folks use trpc))