Ga naar hoofdinhoud

Routing

Tot nu toe bestonden onze applicaties uit één enkele pagina. Dit is niet erg handig als je een volledige website wil maken. Daarom is er een manier nodig om meerdere pagina's te maken. Dit kan met behulp van een router. Een router is een stukje code dat bepaalt welke pagina getoond moet worden op basis van de URL.

In het onderdeel React.js hebben we gebruik gemaakt van een declaratieve manier om routes te definiëren met behulp van de bibliotheek React Router. Je beschrijft dan in je code welke componenten bij welke URL's horen. Dit is een goede manier om routes te definiëren, maar het heeft als nadeel dat je aanzienlijke hoeveelheid code moet schrijven om de router te configureren.

File-system based router

Next.js maakt gebruik van een zogenaamde "file-system based" router. Dit betekend dat we geen extra code moeten schrijven om de router te configureren. We moeten enkel een aantal bestanden en directories aanmaken en de router zal automatisch de juiste pagina tonen op basis van het pad.

Als je een nieuw Next.js project aanmaakt met npx create-next-app@latest dan zal er automatisch een app directory aangemaakt worden. In deze directory kan je nieuwe bestanden en directories aanmaken om nieuwe routes te maken. Er wordt ook al een page.tsx bestand aangemaakt in de app directory.

Nieuwe routes maken

Als je bijvoorbeeld een pagina wilt aanbieden op het pad /about, dan kan je een nieuwe directory about aanmaken in de app directory en daar een nieuw bestand page.tsx aanmaken. Je kan hier zo diep gaan als je zelf wil. Wil je bijvoorbeeld een pagina aanbieden op het pad /dashboard/invoices, dan kan je een directory dashboard aanmaken in de app directory en daar een nieuwe directory invoices aanmaken. In deze directory kan je dan een nieuw bestand page.tsx aanmaken.

alt text

Om te navigeren tussen de verschillende pagina's kan je gebruik maken van het Link component dat meegeleverd wordt met Next.js. Dit component zorgt ervoor dat de navigatie gebeurt zonder dat de pagina volledig herladen wordt. Dit zorgt voor een betere gebruikerservaring. Als je gewoon een <a> element gebruikt, dan zal de pagina volledig herladen worden.

Als we bijvoorbeeld een navigatiebalk willen maken die op de root pagina getoond wordt, dan kunnen we een nieuwe component NavBar maken en deze toevoegen aan de layout.tsx bestand in de app directory.

import Link from "next/link";

const NavBar = () => {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/dashboard/invoices">Invoices</Link>
</nav>
);
};

Je kan in principe zelf kiezen waar je de NavBar component aanmaakt in de directory structuur van je project. Je kan deze bijvoorbeeld in de src/components directory plaatsen. Er wordt ook vaak gekozen om de componenten bij de pagina's te plaatsen waar ze gebruikt worden. In dit geval zou je de NavBar component in de app directory kunnen plaatsen.

Om aan te geven welke link actief is, kan je gebruik maken van de usePathname hook die meegeleverd wordt met Next.js. Deze hook geeft de huidige pathname terug. Je kan deze gebruiken om te bepalen welke link actief is en deze een andere stijl te geven. Deze werkt enkel in client componenten dus meestal zijn navigatiebalken client componenten.

"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

interface NavLinkProps {
href: string;
label: string;
}

const NavLink = ({href, label} : NavLinkProps) => {
const pathname = usePathname();
return (
<Link href={href} className={`px-3 py-2 rounded-md text-sm font-medium ${pathname === href ? "bg-blue-600 text-white" : "text-gray-700 hover:bg-gray-200 hover:text-black"}`}>
{label}
</Link>
)
}

const NavBar = () => {
return (
<nav>
<NavLink href="/" label="Home" />
<NavLink href="/about" label="About" />
<NavLink href="/dashboard/invoices" label="Invoices" />
</nav>
);
};

export default NavBar;

Layouts maken

Je kan ook gebruik maken van layouts om gedeelde componenten te maken die op meerdere pagina's gebruikt worden. Dit is handig als je bijvoorbeeld een navigatiebalk of een footer wil maken die op alle pagina's getoond wordt. Je kan dit doen door een nieuw bestand layout.tsx aan te maken in de directory waar je de layout wil gebruiken. Dus als je een layout wil maken die op alle pagina's gebruikt wordt, dan kan je een nieuw bestand layout.tsx aanmaken in de app directory.

app/layout.tsx → layout voor alle pagina's

Elke nieuwe next applicatie die je aanmaakt met npx create-next-app@latest heeft al een layout.tsx bestand in de app directory. Vereenvoudigd ziet dit bestand er als volgt uit:

const RootLayout = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="en">
<body>{children}</body>
</html>
);
};

export default RootLayout;

Je ziet hier dat de layout een component is die een children prop verwacht. Deze prop bevat de inhoud van de pagina die getoond moet worden. In dit geval wordt de inhoud van de pagina in de body van het HTML document geplaatst.

Wil je bijvoorbeeld op elke pagina een navigatiebalk tonen, dan kan je dit doen door de navigatiebalk component toe te voegen aan de layout:

import NavBar from "@/components/NavBar";

const RootLayout = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="en">
<body>
<NavBar />
{children}
</body>
</html>
);
};

export default RootLayout;

One layout

Je kan ook geneste layouts maken. Dit is handig als je bijvoorbeeld een layout wil maken die alleen op bepaalde pagina's gebruikt wordt. Wil je bijvoorbeeld een layout maken die alleen op de dashboard pagina's gebruikt wordt, dan kan je een nieuw bestand layout.tsx aanmaken in de app/dashboard directory.

app/dashboard/layout.tsx → layout voor alle dashboard pagina's

Stel je voor dat we een speciale layout willen maken voor alle dashboard pagina's. Deze layout kan er als volgt uitzien:

import SideBar from "@/components/SideBar";

const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
return (
<div style={{ display: "flex" }}>
<SideBar />
<div>
{children}
</div>
</div>
);
};

export default DashboardLayout;

Merk hierbij op dat eerst de Layout in de app directory wordt toegepast en daarna de layout in de app/dashboard directory. Dit betekend dat de NavBar component altijd getoond wordt, ook op de dashboard pagina's.

Als je naar /dashboard/invoices navigeert, dan zal de volgende structuur getoond worden:

Geneste Layouts

Imperatieve navigatie

In sommige gevallen wil je misschien imperatief navigeren naar een andere pagina. Dit kan bijvoorbeeld handig zijn na het indienen van een formulier. In een client component kan je hiervoor de useRouter hook gebruiken die meegeleverd wordt met Next.js. Deze hook geeft een router object terug waarmee je kan navigeren.

"use client";

import { useRouter } from "next/navigation";

const MyComponent = () => {
const router = useRouter();

const handleClick = () => {
router.push("/about");
};

return (
<button onClick={handleClick}>Go to About Page</button>
);
};
export default MyComponent;

Je hebt ook nog de volgende methodes beschikbaar op het router object:

  • replace(href: string): Navigeert naar de opgegeven URL, maar vervangt de huidige entry in de geschiedenis in plaats van een nieuwe entry toe te voegen.
  • back(): Navigeert terug naar de vorige pagina in de geschiedenis.
  • forward(): Navigeert vooruit naar de volgende pagina in de geschiedenis.
  • refresh(): Vernieuwt de huidige pagina, waarbij alle data opnieuw wordt opgehaald. De state van client componenten blijft behouden.

Dynamische routes

Je kan ook dynamische routes maken. Dit is handig als je bijvoorbeeld een pagina wil maken die de details van een bepaald item toont. Stel je voor dat we een pagina hebben die een lijst van producten toont. Als je op een product klikt, wil je naar een pagina navigeren die de details van dat product toont.

Stel dat we een lijst van producten hebben zoals hieronder:

interface Product {
id: number;
name: string;
}

const products : Product[] = [
{ id: 1, name: "Product 1" },
{ id: 2, name: "Product 2" },
{ id: 3, name: "Product 3" },
];

export default products;

We kunnen dan een nieuwe pagina maken die de lijst van producten toont. Dit kan in het bestand src/app/products/page.tsx:

import Link from "next/link";
import products from "@/data/products";

const ProductsPage = () => {
return (
<div>
<h1>Products</h1>
<ul>
{products.map((product) => (
<li key={product.id}>
<Link href={`/products/${product.id}`}>{product.name}</Link>
</li>
))}
</ul>
</div>
);
};

export default Products;

We gaan hier uiteraard geen drie aparte pagina's maken voor elk product. In plaats daarvan gaan we een dynamische route maken. Dit kan door een nieuwe directory aan te maken in de app/products directory met de naam [id]. De naam tussen de vierkante haken geeft aan dat dit een dynamische parameter is. In deze directory kunnen we dan een nieuw bestand page.tsx aanmaken.

const ProductsDetail = async(props: PageProps<"/products/[id]">) => {
const { id } = await props.params;

return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Product Detail for ID: {id}</h1>
<p className="text-lg">This is the detail page for product with ID {id}.</p>
</div>
);
}

export default ProductsDetail;

Merk op dat we hier een speciaal type PageProps gebruiken om de props van de pagina te typeren. Dit type wordt meegeleverd door Next.js en zorgt ervoor dat we toegang hebben tot de dynamische parameters in de URL. In dit geval is er één parameter id die we kunnen gebruiken om de details van het product op te halen. Omdat het ophalen van parameters in Next.js asynchroon is, maken we de component async en gebruiken we await om de parameters op te halen.

Als je deze wil uitlezen in een client component, dan kan je gebruik maken van de useParams hook die meegeleverd wordt met Next.js. Deze hook geeft een object terug met de dynamische parameters.

"use client";

import { useParams } from "next/navigation";

const ProductDetailClient = () => {
const params = useParams();
const { id } = params;

return (
<div>
<h1>Product Detail for ID: {id}</h1>
<p>This is the detail page for product with ID {id}.</p>
</div>
);
};
export default ProductDetailClient;

not-found.tsx

Je kan ook een not-found.tsx bestand aanmaken in een directory om een custom 404 pagina te maken voor alle componenten in die directory. Je plaatst deze file in dezelfde directory als waar je de 404 pagina wil toepassen.

const NotFound = () => {
return (
<div>
<h1>404 - Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
</div>
);
};
export default NotFound;

Als je er voor wil zorgen dat een bepaalde pagina de 404 pagina toont, kan je de notFound functie aanroepen die meegeleverd wordt met Next.js. Deze functie zorgt ervoor dat de 404 pagina getoond wordt.

"use client";
import { useParams, notFound } from "next/navigation";

const ProductDetailClient = () => {
const params = useParams();
const { id } = params;

if (id !== "1" && id !== "2" && id !== "3") {
notFound();
}

return (
<div>
<h1>Product Detail for ID: {id}</h1>
<p>This is the detail page for product with ID {id}.</p>
</div>
);
};
export default ProductDetailClient;

Grouped routes

Je kan ook gebruik maken van grouped routes om je directory structuur overzichtelijker te maken. Dit is handig als je bijvoorbeeld een aantal pagina's hebt die bij elkaar horen, maar je wil niet dat deze pagina's een extra niveau in de URL krijgen. Je kan ook op deze manier gedeelde layouts maken voor een groep pagina's zonder dat dit invloed heeft op de URL structuur.

Je kan een grouped route maken door een directory aan te maken met de naam (group-name). De naam tussen de ronde haken geeft aan dat dit een grouped route is. In deze directory kan je dan nieuwe bestanden en directories aanmaken zoals je normaal zou doen.

Search parameters

Server component

In een server component (zoals een pagina of layout) kan je ook gebruik maken van search parameters (of query parameters). Dit zijn de parameters die in de URL staan na het vraagteken (?). Stel dat we een pagina hebben die een lijst van producten toont en we willen deze lijst filteren op basis van een zoekterm. We kunnen dan de zoekterm als een search parameter in de URL meegeven, bijvoorbeeld /products?q=shirt.

interface Product {
id: number;
name: string;
}

const products : Product[] = [
{ id: 1, name: "Red Shirt" },
{ id: 2, name: "Blue Jeans" },
{ id: 3, name: "Green Hat" },
{ id: 4, name: "Yellow Jacket" },
{ id: 5, name: "Black Shoes" },
{ id: 6, name: "White Socks" },
];

const ProductsPage = async(props: PageProps<"/products">) => {
const searchParams = await props.searchParams;
const q = typeof searchParams.q === "string" ? searchParams.q : "";
const filteredProducts = products.filter(product => product.name.startsWith(q));

return (
<div>
<h1>Products</h1>
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>
<Link href={`/products/${product.id}`}>{product.name}</Link>
</li>
))}
</ul>
</div>
);
}

export default ProductsPage;

Opgelet deze dingen zijn enkel mogelijk in server componenten.

Search parameters in client componenten

Om de search parameters te gebruiken in een client component, kan je gebruik maken van de useSearchParams hook die meegeleverd wordt met Next.js. Deze hook geeft een URLSearchParams object terug dat je kan gebruiken om de search parameters op te halen.

"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { FormEventHandler, useState } from "react";

const SearchBox = () => {
const searchParams = useSearchParams();
const { replace } = useRouter();
const q: string = searchParams.get("q") ?? "";
const [searchField, setSearchField] = useState<string>(q);

const onSubmit : FormEventHandler<HTMLFormElement> = (e) =>{
e.preventDefault();
const params = new URLSearchParams(searchParams.toString());
if (searchField !== "") {
params.set("q", searchField)
} else {
params.delete("q");
}

replace(`?${params.toString()}`)
}

return (
<div>
<form onSubmit={onSubmit}>
<input
className="border-2 border-black"
type="search"
value={searchField}
onChange={(e) => setSearchField(e.target.value)}
/>

<button type="submit">Search</button>
</form>
</div>
)
}

export default SearchBox;

Voorbeeld: Posts sorteren aan de hand van query parameters

We kunnen een client component maken die de sortering van posts aanpast op basis van query parameters in de URL. We maken hiervoor een SortSelect component die twee select elementen bevat: één voor het veld waarop gesorteerd wordt en één voor de richting van de sortering (oplopend of aflopend).

"use client";

import { SortDirection, SortField } from "@/app/types";
import { useRouter, useSearchParams } from "next/navigation";
import { SortDirection, SortField } from "@/types";

const SortSelect = () => {
const searchParams = useSearchParams();
const { replace } = useRouter();

const sortField : SortField = (searchParams.get("sortField") || "id") as SortField;
const sortDirection : SortDirection = (searchParams.get("sortDirection") || "asc") as SortDirection;

const onChangeSortField: React.ChangeEventHandler<HTMLSelectElement> = (e) => {
const newSort = e.target.value;
const params = new URLSearchParams(searchParams.toString());
if (newSort) {
params.set("sortField", newSort);
} else {
params.delete("sortField");
}
replace(`?${params.toString()}`);
};

const onChangeSortDirection: React.ChangeEventHandler<HTMLSelectElement> = (e) => {
const newSort = e.target.value;
const params = new URLSearchParams(searchParams.toString());
if (newSort) {
params.set("sortDirection", newSort);
} else {
params.delete("sortDirection");
}
replace(`?${params.toString()}`);
};

return (
<div>
<label htmlFor="sort" className="mr-2 font-medium">Sort by:</label>
<select id="sortField" name="sortField" className="border border-gray-300 rounded-md p-2" onChange={onChangeSortField} value={sortField}>
<option value="id">ID</option>
<option value="name">Name</option>
</select>

<label htmlFor="sortDirection" className="ml-4 mr-2 font-medium">Direction:</label>
<select id="sortDirection" name="sortDirection" className="border border-gray-300 rounded-md p-2" onChange={onChangeSortDirection} value={sortDirection}>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</div>
);
}

export default SortSelect;

Deze kan je dan als volgt gebruiken om de sortering van een lijst van posts aan te passen:

import Link from "next/link";
import { SortDirection, SortField } from "@/types";

interface Product {
id: number;
name: string;
}

const products : Product[] = [
{ id: 1, name: "Red Shirt" },
{ id: 2, name: "Blue Jeans" },
{ id: 3, name: "Green Hat" },
{ id: 4, name: "Yellow Jacket" },
{ id: 5, name: "Black Shoes" },
{ id: 6, name: "White Socks" },
];

const ProductsPage = async(props: PageProps<"/products">) => {
const searchParams = await props.searchParams;
const sortField : SortField = (typeof searchParams.sortField === "string" ? searchParams.sortField : "id") as SortField;
const sortDirection : SortDirection = (typeof searchParams.sortDirection === "string" ? searchParams.sortDirection : "asc") as SortDirection;

const sortedProducts = products.sort((a, b) => {
let fieldA = a[sortField];
let fieldB = b[sortField];

if (fieldA < fieldB) return sortDirection === "asc" ? -1 : 1;
if (fieldA > fieldB) return sortDirection === "asc" ? 1 : -1;
return 0;
});

return (
<div>
<h1>Products</h1>
<SortSelect />
<ul>
{sortedProducts.map((product) => (
<li key={product.id}>
<Link href={`/products/${product.id}`}>{product.name}</Link>
</li>
))}
</ul>
</div>
);
}

export default ProductsPage;

Voorbeeld: Paginatie met query parameters

"use client";

import { useSearchParams, useRouter } from "next/navigation";
interface PaginationProps {
pageCount: number;
currentPage: number;
}

const Pagination = ({ pageCount, currentPage }: PaginationProps) => {
const searchParams = useSearchParams();
const { replace } = useRouter();

const changePage = (newPage: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", newPage.toString());
replace(`?${params.toString()}`);
}

return (
<div className="flex justify-center mt-4 space-x-2">
{Array.from({ length: pageCount }, (_, i) => i + 1).map(page => (
<button key={page} onClick={() => changePage(page)} className={`px-3 py-1 rounded ${page === currentPage ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'}`}>
{page}
</button>
))}
</div>
);
};
export default Pagination;

Terug naar het producten voorbeeld, we kunnen nu een paginatie component maken die de pagina's toont op basis van een page query parameter in de URL. We definiëren eerst een constante PAGE_SIZE die aangeeft hoeveel producten er per pagina getoond moeten worden.

import Pagination from "@/components/Pagination";


interface Product {
id: number;
name: string;
}

const products : Product[] = [
{ id: 1, name: "Red Shirt" },
{ id: 2, name: "Blue Jeans" },
{ id: 3, name: "Green Hat" },
{ id: 4, name: "Yellow Jacket" },
{ id: 5, name: "Black Shoes" },
{ id: 6, name: "White Socks" },
];

const PAGE_SIZE = 3;

const ProductsPage = async(props: PageProps<"/products">) => {
const searchParams = await props.searchParams;
const page = parseInt(typeof searchParams.page === "string" ? searchParams.page : "1");

const pageCount = Math.ceil(products.length / PAGE_SIZE)
const productsByPage = products.slice(((page-1) * PAGE_SIZE), ((page) * PAGE_SIZE));

return (
<div>
<h1>Products</h1>
<Pagination pageCount={pageCount} currentPage={page} />
<ul>
{productsByPage.map((product) => (
<li key={product.id}>
<Link href={`/products/${product.id}`}>{product.name}</Link>
</li>
))}
</ul>
</div>
);
}
export default ProductsPage;