In this post I'm going to show you how I usually handle routes in Next.js (and plain React projects)
This is second part out of two so if you haven't read the first one I recommend you to do it first.
The problem
In the first part of these series, we transitioned from hardcoded routes to the use of a new function called getRoute
, where we can pass the name of the route as it is defined in
the ROUTES
object in order to resolve the path we want. Also, we handled route parameters through this function, but we still have more pending tasks to do for this to be complete.
We need a provider or, in other words, a context.
I'm sure you had to deal with a CMS or any other type of site where you need to remind the user to save the changes before they leave the page. How many times have you built these confirmation alerts having the logic individually in each page? Or maybe you have a URL with at least one parameter that must be always present, like a workspace or
username (e.g: https://demo.com/[company-name or user-name]/profile
). Or maybe you have some specific logic that needs to be run on every route change.
By having a provider we can centralized as well as avoiding repetition by handling all these things in one place.
Solution
Let's first start by creating a new folder called CustomRouterProvider where we are going to place just an index.tsx for now. In the file let's add the following code.
"use client";
import { createContext, PropsWithChildren, useContext } from "react";
import { NavigateOptions } from "next/dist/shared/lib/app-router-context";
import { usePathname, useRouter } from "next/navigation";
import { AvailableRoutes, getRoute } from "@/config/routes";
type CustomOptions = NavigateOptions & {
askBeforeLeave?: boolean;
// You can keep adding things here or change this completely. This is just an example
};
type CustomPush = (
route: AvailableRoutes,
params?: Record<string, string>,
options?: CustomOptions,
) => void;
type CustomReplace = (
route: AvailableRoutes,
params?: Record<string, string>,
options?: CustomOptions,
) => void;
type CustomRouterContext = {
pathname: string;
push: CustomPush;
replace: CustomReplace;
};
const Context = createContext<CustomRouterContext>({
pathname: "",
push: () => undefined,
replace: () => undefined,
});
export function CustomRouterProvider({ children }: PropsWithChildren) {
const pathname = usePathname();
const { push: nextPush, replace: nextReplace } = useRouter();
const push: CustomPush = (route, params, options) => {
if (
options?.askBeforeLeave &&
!confirm(
"You have unsaved changes. Are you sure you want to leave this page?",
)
) {
return;
}
// These functions and this provider can be customized as much as you want!
nextPush(getRoute(route, params), {
scroll: options?.scroll,
});
};
const replace: CustomReplace = (route, params, options) => {
if (options?.askBeforeLeave) {
// Implement logic to show a confirmation alert.
// You can use the native JavaScript confirm or implement a nicer one.
// It's up to you
}
nextReplace(getRoute(route, params), {
scroll: options?.scroll,
});
};
return (
<Context.Provider
value={{
pathname,
push,
replace,
}}
>
{children}
</Context.Provider>
);
}
export function useCustomRouter() {
return useContext(Context);
}
Note: This example is for Next.js using the /app directory but you can do the same if you are using the /pages one or even React Router in a React application. Also, I like to keep everything related to a specific context in the same file as you see but, if preferred, you can split it in multiple ones.
Let's dive into the code above and allow me to omit the types for now.
First of all, we create a React context by making use of the createContext
function available in the React API, we also define a default value to it with the properties and functions that are going to be available.
Then, we define the provider CustomRouterProvider which contains the most interesting part. Here, we are redefining the native push
and replace
functions coming from the Next.js router so we can add our own logic
before calling them. This enables a lot of possibilities that now we can handle in just one place!
Finally, we are making use of the useContext
hook also available in the React API and we are passing our context to it so then we can just use our own hook useCustomRouter()
.
Now we have to wrap our application with the new provider we just created.
We should have something like this. Remember that the level in which you choose to add the provider will depend exclusively on your application and your needs.
// app/layout.tsx
export default function RootLayout({
children,
params,
}: Readonly<{
children: React.ReactNode;
params: Record<string, string>;
}>) {
return (
<html lang={params.locale}>
<body>
<CustomRouterProvider>
<Layout>{children}</Layout>
</CustomRouterProvider>
</body>
</html>
);
}
Note: In a React application without Next.js this code could be in the global index.tsx file that contains your entire application.
Finally we can make use of our new context created previously:
import { useCustomRouter } from "@path/to/CustomRouterProvider";
export default MyReactPage() {
const { push } = useCustomRouter();
return (
<div>
<h1>Navigate to another page</h1>
<a onClick={() => push("aboutUs")}>About Us</a>
</div>
)
}
Note: Next.js recommends the use of the Link component since they do a pre load of the pages that are linked to the current one. With this approach we are losing that but we can still use the Link component as we saw in the first part of this guide, of course, without the custom provider and all its features, it's a tradeof where we'll have to evaluate according to our needs.