1. Routing, Actions & Components
1. Routing, Actions & Components
Tijdens deze les maken we kennis met de app router uit Next.js. Daarnaast bespreken we het verschil tussen server en clients components en hoe we server actions en server functions kunnen gebruiken om data te muteren.
De startbestanden voor deze les zijn anders opgebouwd dan bij de frontend-lessen. Omwille van de complexiteit van het project is een deel van de lesinhoud al geïmplementeerd, anders zouden er te veel bestanden verplaatst moeten worden binnen het project, wat verwarrend kan zijn.
Startbestanden
Info
Het lesvoorbeeld maakt gebruik van een gesimuleerde database, vanaf volgende les wordt deze simulatie vervangen met een ORM. Kopieer deze simulatie dus in geen geval naar je eigen project.
De simulatie schrijf een JSON-bestand naar één van onderstaande directories, je kan deze map verwijderen als je met een schone lei wilt starten.
- Windows: %APPDATA%/backend_frameworks_db_simulation
- macOS: ~/Library/Preferences/backend_frameworks_db_simulation
- Linux: ~/.local/share
Next.js
Tijdens deze lessenreeks bespreken we het meta-framework Next.js. Next.js is een framework dat bovenop React gebouwd is en dat een hechte integratie tussen server en client biedt.
Next renderd statische pagina's als HTML, cached het resultaat eventueel, en stuurt dit door naar de client. Hierdoor is de tijd voor First Contentful Paint (FCP)[1] lager dan bij een SPA en lijkt de applicatie sneller voor de gebruiker, wat een positieve impact heeft op de user experience.
Het is vanzelfsprekend dat niet elke pagina statisch is, voor interactieve pagina's wordt de pagina deels of volledig gerenderd op de server, naar de client gestuurd als HTML en daarna terug actief gemaakt via hydration.
Begrip: Hydration & Server Side Rendering
Een applicatie die gebruikt maakt van Server Side Rendering (SSR) stuurt een React component naar de client als HTML.
Zodra de client (browser) de HTML gerenderd heeft, doorloopt de pagina het hydration process. Dit process zorgt ervoor dat React gekoppeld wordt aan de DOM-elementen die de server gestuurd heeft. React neemt hierbij dus de controle van de DOM-elementen over en koppelt eventuele event handlers.
Het is belangrijk dat de data die door de server gestuurd wordt overeenkomt komt met de data die op de client gerenderd wordt tijdens het hydration process. Als dat niet zo is, kan dit leiden tot hydration errors. Foutieve geneste tags zoals p > button en bepaalde functies zoals Date.toLocaleString() zijn enkele voorbeelden van zaken die zulke errors kunnen veroorzaken.
In de meeste gevallen is het eenvoudig om hydration errors op te lossen, maar in sommige gevallen is het moeilijk of zelfs onmogelijk. De Date.toLocaleString() functie is een voorbeeld van een onvermijdelijke hydration error omdat deze functie afhankelijk is van de taalinstellingen van de server (waar de initiële HTML-code gegenereerd wordt) en de client (waar de functie opnieuw opgeroepen wordt tijdens het hydration process). Omdat de taalinstellingen waarschijnlijk verschillen tussen server en client, is de datum anders geformatteerd en wordt er een foutmelding gegenereerd.
In dit geval kan de booleaanse suppressHydrationWarning property toegevoegd worden aan de directe parent van het HTML-tag waarin de fout zich voordoet. Zo wordt de foutmelding verborgen. Dit is natuurlijk een escape hatch die je best zo weinig mogelijk gebruikt.
In het geval een gebruiker wisselt tussen pagina's zal Next de delen die al geladen waren, e.g. een navigatiebalk, herbruiken. De onderdelen van de pagina die nog niet geladen waren, worden dan op de server gerenderd, doorgegeven aan de client en gehydrateerd.
Door deze mix van server en client code krijgen we een applicatie die de voordelen van een SPA (caching, snelle navigatie, page transitions, ...) combineert met de voordelen van een klassieke MPA[2] (SEO, FCP, veiligheid, data fetching volledig op de server, snelheid, ...).
Project aanmaken
Bovenstaande startbestanden bevatten reeds een Next.js project waarin Tailwind, Shadcn, ESLint en Prettier geïntegreerd zijn. Ondanks dat de startbestanden al een project bevatten, is het wel cruciaal dat je zelf een project kan aanmaken, daarom bespreken we de procedure hieronder.
Om een Next.js project aan te maken begin je met onderstaand commando uit te voeren. Hierin wordt project-naam natuurlijk vervangen met de echte naam van je project. Kies voor custom i.p.v. recommended.
De cli vraagt in eerste instantie of we de default settings willen gebruiken of deze willen aanpassen. We kiezen hier om om de instellingen zelf aan te passen.
? Would you like to use the recommended Next.js defaults? › - Use arrow-keys. Return to submit.
Yes, use recommended defaults
No, reuse previous settings
❯ No, customize settings - Choose your own preferencesVervolgens worden er enkele vragen gesteld over de configuratie van het project, we kiezen steeds voor Yes. Het is vanzelfsprekend dat we TypeScript, ESLint en Tailwind CSS willen gebruiken. De configuratie van ESLint is grotendeels gelijk aan de configuratie die we gebruiken in de frontend-lessen, voor meer informatie verwijzen we door naar de appendix.
Het gebruikt van de src directory is optioneel binnen Next.js, code kan ook rechtstreeks in de root van je project geplaatst worden. Doorheen deze cursus gebruiken we de src directory om alle React/TypeScript code af te zonderen van de configuratie bestanden in de root van het project.
Het is cruciaal dat je kiest voor de App Router, dit is een belangrijk onderdeel van deze lessenreeks. Zo goed als alle theorie werkt enkele voor deze router.
✔ Would you like to use TypeScript? … no / Yes
✔ Which linter would you like to use? > ESLint
✔ Would you like to use React Compiler? › No / yes
✔ Would you like to use Tailwind CSS? … no / Yes
✔ Would you like to use `src/` directory? … no / Yes
✔ Would you like to use Turbopack? (recommended) … no / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / yesDe configuratie van Shadcn is veel eenvoudiger dan bij de frontend-lessen. We moeten slechts één commando uitvoeren, waarna er enkele vragen gesteld worden die al besproken zijn in de frontend lessen.
App Router
Begrip: App Router
Next.js gebruikt een file-based routing systeem, de App Router. Dit betekent dat de bestandsstructuur van je project bepaalt welke routes er beschikbaar zijn in de applicatie.
Elke subfolder van de /src/app directory wordt een nieuw segment in de URL. Binnen deze mappen moet een bestand met de naam page.tsx aanwezig zijn dat via een default export een React component exporteert.
Op het bovenste niveau van de routing-structuur moet ook een layout route aanwezig zijn.
Root-layout
Begrip: Layout route
Een layout route is een bestand met de naam layout.tsx dat een gedeelde layout bevat voor alle onderliggende pagina's. Dit betekent dat de layout in /src/app/layout.tsx toegepast wordt op alle pagina's in de applicatie. Er kan maximaal layout per subdirectory van de /src/app folder toegevoegd worden.
Layout routes worden niet herladen als de gebruiker navigeert tussen de verschillende geneste pagina's die onder dezelfde layout route vallen. Voor de root-layout betekent dit dus dat deze nooit herladen wordt, tenzij deze pagina expliciet geïnvalideerd wordt.
De layout route op het root niveau moet een geldig HTML5 document bevatten, inclusief de <html>, <head> en <body> tags. Onderstaande code toont een minimaal voorbeeld.
const RootLayout: FunctionComponent<PropsWithChildren> = ({children}) => {
return (
<html>
<head>
<title>My App</title>
</head>
<body>{children}</body>
</html>
)
}
export default RootLayout:::
Nadat we bovenstaande layout toegevoegd hebben aan onze applicatie moeten we nog een /src/app/page.tsx toevoegen voordat de applicatie opgestart kan worden. De startbestanden bevat een HomePage component die hiervoor gebruikt kan worden, we hernoemen dit bestand dus naar page.tsx. De /src/app folder ziet er nu als volgt uit.

Metadata
Eén van de grote voordelen van Next.js is dat het veel betere ondersteuning biedt voor search engine optimization dan Vite. Pagina's die statisch zijn kunnen gepre-renderd worden met hun eigen metadata (title, description, ...). Zoekmachines kunnen deze pagina's dan detecteren en indexeren.[3]
Begrip: Metadata in Next.js
Elke pagina of layout-route in Next.js kan metadata definiëren die gebruikt wordt om de titel, description en andere onderdelen van de pagina aan te passen. Sommige van de gegevens die hierin geconfigureerd worden kunnen meegenomen worden door Google en andere zoekmachines.
De metadata wordt geëxporteerd als een named export in een pagina of layout-route. Als dit object geëxporteerd wordt uit de root-layout moet deze layout-route geen <head> tag bevatten omdat dit tag automatisch gegenereerd wordt door Next.js op basis van de metadata.
import {Metadata} from 'next'
export const metadata: Metadata = {
title: 'My search engine optimized website.',
description: 'A Next.js website with metadata.',
}In deze voorbeeldapplicatie voegen we enkel metadata toe aan de root-layout, in een echte applicatie gebruik je beter gespecialiseerde metadata voor elke component. Hiervoor kan je eventueel templates gebruiken die een deel van de metadata definiëert in een layout-route en die dan uitgebreid wordt op een pagina. We verwijzen de geïnteresseerde lezer door naar de Next.js documentatie.
Aangezien deze root-layout file rond de volledige applicatie gezet wordt, is dit ook de ideale plaats om de Tailwind CSS in te lezen. Daarnaast plaatsen we de Navbar (uit de startbestanden) ook rond de children.
import '@/assets/globals.css'
import type {FunctionComponent, PropsWithChildren} from 'react'
import type {Metadata} from 'next'
import Navbar from '@/app/navbar'
export const metadata: Metadata = {
title: 'Les 1: Voorbeeld | Backend frameworks',
description: 'De voorbeeldcode voor les 1 van het vak Backend frameworks.',
authors: [
{
name: 'PIT Graduaten',
},
],
}
const RootLayout: FunctionComponent<PropsWithChildren> = ({children}) => {
return (
<html>
<body><Navbar>{children}</Navbar></body>
</html>
)
}
export default RootLayoutDe website ziet er nu als volgt uit.

Routing groups
Om de folderstructuur overzichtelijk te houden maken we gebruik van routing groups. Dit zijn mappen in de app folder die niet omgevormd worden tot een URL-segment, maar enkel dienen om pagina's te groeperen per functie, gebruikersrol, ... Een routing group wordt aangeduid met ronde haken rond de mapnaam.
Alhoewel we deze les nog niet met gebruikers werken, kunnen we hiervoor al we een routing group voorzien zodat we deze functionaliteit in latere lessen eenvoudig kunnen toevoegen zonder de bestandsstructuur aan te passen.

Routing opt-out
Momenteel is de inhoud van de /src/app/(authenticated)/contacts folder redelijk onoverzichtelijk. Er zitten heel wat bestanden in eenzelfde map.

Om de structuur van de map te verbeteren kunnen we gebruik maken van mappen die beginnen met een underscore. Op deze manier negeert de app router de map en alle bestanden die erin zitten.

Not found
Het is vanzelfsprekend dat een applicatie een 404 pagina moet tonen als een gebruiker naar een niet bestaande pagina probeert te navigeren, zoals de "/account" link in de NavBar. Ook hiervoor voorziet Next een speciale file, not-found.tsx. Per page.tsx kan één not-found.tsx toegevoegd worden, we voegen in deze cursus slechts één 404 pagina toe op het root-niveau.
De startbestanden bevatten de code voor de 404 pagina in 404Page.tsx, we hernoemen deze dus naar not-found.tsx en krijgen zo onderstaand resultaat als we op de "Account" link drukken.

Error boundary
De routing structuur is bijna afgewerkt, er ontbreekt nog één belangrijke file. De /contacts route bevat voorlopig nog een fout, als we op de "My Contacts" link klikken, wordt onderstaande foutmelding getoond.

Next heeft de foutmelding opgevangen en een duidelijke foutboodschap getoond waarmee een developer het probleem kan oplossen. Dit is ideaal tijdens het ontwikkelen van de applicatie, maar moest er iets mis gaan als de applicatie in productie draait, kan Next zo'n foutmelding niet tonen. De reden hiervoor is tweeledig. Enerzijds heeft een gebruiker geen boodschap aan een foutmelding met code, dit zou de gebruiker zelfs kunnen afschrikken. Anderzijds geeft zo'n foutmelding ook te veel informatie over de interne werking van de applicatie (of toch als de foutmelding op de server gegenereerd is in plaats van op de client).
Om dit probleem op te lossen voegen we een custom error boundary toe die een gebruiksvriendelijke foutboodschap toont. Next verwacht opnieuw dat dit bestand een specifieke naam heeft, in dit geval error.tsx. Daarnaast is het wederom mogelijk om verschillende error boundaries toe te voegen, één voor elke page.tsx. Een Error component moet een client component zijn.
In de startbestanden is errorPage.tsx voorzien, als we dit bestand hernoemen naar error.tsx krijgen we onderstaand resultaat.

Merk op dat de duidelijke foutmelding voor developers nog steeds te zien is door onderaan de pagina op de rode Next-knop te drukken. Tijdens productie is deze knop natuurlijk niet meer beschikbaar.
Server components
Om het probleem op de contacten pagina op te lossen moeten we een de contacten opgehaald worden uit de database (simulatie).
Begrip: React Server Components
React Server Components (RSC) zijn componenten die enkel op de server uitgevoerd kunnen worden en eventueel asynchroon zijn. Omdat een server component op de server uitgevoerd wordt, kan zo'n component gebruikt worden om een database of bestandssysteem aan te spreken voordat er iets naar de client gestuurd wordt. Hierdoor zijn er minder HTTP request nodig dan bij een SPA, het ophalen van de HTML en de data gebeurt in één keer in de plaats van in twee aparte stappen.
Omdat een RSC enkel op de server uitgevoerd wordt, is het niet mogelijk om hooks zoals useState of useEffect te gebruiken, deze zijn namelijk afhankelijk van de client/de acties van een gebruiker en steunen eventueel op browser API's die natuurlijk niet beschikbaar zijn op de server.
De JSX-code die gegenereerd wordt in een server component moet serialiseerbaar zijn. Dit betekent dat het onmogelijk is om functies (event handlers) te koppelen in een server component. Serialisatiefuncties zoals JSON.stringify verwijderen functies namelijk uit objecten als deze geconverteerd worden naar een JSON-bestand.
Dit betekent niet dat we geen functies kunnen oproepen of gebruiken in een RSC. Zolang deze niet gekoppeld worden aan een JSX-element, maar gebruikt worden om te bepalen welke JSX-code er gerenderd moet worden is er geen probleem.
Binnen Next.js is elke component per default een server component, een component met hooks of event handlers moet expliciet aangeduide worden als een client component via het 'use client' directive.
We kunnen de contacten en favoriete contacten dus rechtstreeks uitlezen uit de database zonder dat we fetch, TanStack Query, SWC, ... moeten gebruiken.
const ContactsPage: FunctionComponent = async () => {
const contacts = await getContacts('')
return (
<>
...
</>
)
}const FavoritesList: FunctionComponent = async () => {
const favorites = await getFavoriteContacts()
return (
<>
{favorites.map(f => (
<FavoriteCard key={f.id} {...f} />
))}
</>
)
}Streaming
Zoals bovenstaande video toont, worden de contacten nu wel getoond, maar duurt het relatief lang voordat de pagina geladen is. Dit komt doordat er een artificiële timeout ingebouwd is in de DAL-functies waarmee de contacten opgehaald worden.
Begrip: Loading.tsx
In de Next app router kan voor elke page.tsx een loading.tsx toegevoegd worden. Terwijl page.tsx data aan het laden is, wordt de inhoud van loading.tsx getoond via Suspense.
Net zoals een layout file wordt een loading file toegepast op alle kindroutes.
We gebruiken de contacten pagina als basis om de inhoud van loading.tsx op te bouwen. De cards in de "Favorites" en "My contacts" secties worden vervangen door de ContactCardSkeleton en FavoriteSkeleton componenten.
import type {FunctionComponent} from 'react'
import PageTitle from '@/components/custom/pageTitle'
import FavoriteSkeleton from '@/app/(authenticated)/contacts/_favoriteContacts/favoriteSkeleton'
import {Button} from '@/components/ui/button'
import {Plus} from 'lucide-react'
import ContactCardSkeleton from '@/app/(authenticated)/contacts/_contactCard/contactCardSkeleton'
import {Input} from '@/components/ui/input'
const Loading: FunctionComponent = () => {
return (
<>
<div className="container mx-auto p-4 grid">
<div>
<div className="mb-6">
<PageTitle>Favorites</PageTitle>
</div>
<FavoriteSkeleton />
</div>
<div>
<div className="flex justify-between items-center mb-6">
<PageTitle>My contacts</PageTitle>
<Button>
<Plus /> Add New Contact
</Button>
</div>
<form>
<Input placeholder="Search your contacts" />
</form>
<ContactCardSkeleton />
</div>
</div>
</>
)
}
export default LoadingNadat deze file toegevoegd is, is de UX aanzienlijk verbeterd. De website toont nu duidelijk dat de data geladen wordt en blijft niet op de homepagina staan tot dat de contacten geladen zijn.
Suspense
Alhoewel bovenstaande code al een hele verbetering is, kunnen we de gebruikerservaring nog verder verbeteren door gebruik te maken van suspense op onderdelen van de pagina die trager laden dan de rest.
In het voorbeeld duurt het dubbel zo lang om favorieten op te halen als de algemene contacten. Als we een suspense-boundary toevoegen worden de contacten getoond zodra ze geladen zijn en wordt een skeleton gebruikt tot de favorieten geladen zijn.
const ContactsPage: FunctionComponent = async () => {
const contacts = await getContacts('')
return (
<>
<div className="container mx-auto p-4 grid">
<div>
<div className="mb-6">
<PageTitle>Favorites</PageTitle>
</div>
<div className="flex flex-row gap-4">
<Suspense fallback={<FavoriteSkeleton />}>
<FavoritesList />
</Suspense>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-6">
<PageTitle>My contacts</PageTitle>
<Button>
<Plus /> Add New Contact
</Button>
</div>
<form>
<Input placeholder="Search your contacts" />
</form>
{contacts.map(c => (
<ContactCard {...c} key={c.id} />
))}
</div>
</div>
</>
)
}Query parameters
Op de contactenpagina is een formulier te vinden waarmee de contacten gefilterd zouden moeten worden.
Om dit formulier werkend te krijgen, moeten we het inputelement een naam geven, we gebruiker hieronder contactName. Vervolgens passen we HTTP-methode van het formulier aan, zodat er een HTTP GET request verstuurd worden bij het inzenden.
const ContactsPage: FunctionComponent = async () => {
const contacts = await getContacts('')
return (
<>
<div className="container mx-auto p-4 grid">
<div>
<div className="mb-6">
<PageTitle>Favorites</PageTitle>
</div>
<div className="flex flex-row gap-4">
<Suspense fallback={<FavoriteSkeleton />}>
<FavoritesList />
</Suspense>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-6">
<PageTitle>My contacts</PageTitle>
<Button>
<Plus /> Add New Contact
</Button>
</div>
<form method="get">
<Input placeholder="Search your contacts" name="contactName" />
</form>
{contacts.map(c => (
<ContactCard {...c} key={c.id} />
))}
</div>
</div>
</>
)
}De combinatie van een benoemd input element en de GET-methode, betekent dat de ingegeven tekst in de adresbalk als een queryparameter.
Om de queryparameters uit te lezen gebruiken we de properties van de ContactsPage component. Aan elke pagina in de app router wordt (door Next) de searchParams property toegevoegd, deze parameter bevat een Promise die een object teruggeeft. Elke queryparameter wordt een element in dit object.
Een query parameter is altijd optioneel, daarom gebruiken we een vraagteken in de typedefinitie.
interface ContactsPageProps {
searchParams: Promise<{
contactName?: string
}>
}
const ContactsPage: FunctionComponent<ContactsPageProps> = async ({searchParams}) => {
const {contactName} = await searchParams
const contacts = await getContacts(contactName ?? '')
return (
<>
<div className="container mx-auto p-4 grid">
<div>
<div className="mb-6">
<PageTitle>Favorites</PageTitle>
</div>
<div className="flex flex-row gap-4">
<Suspense fallback={<FavoriteSkeleton />}>
<FavoritesList />
</Suspense>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-6">
<PageTitle>My contacts</PageTitle>
<Button>
<Plus /> Add New Contact
</Button>
</div>
<form method="get">
<Input placeholder="Search your contacts" name="contactName" />
</form>
{contacts.map(c => (
<ContactCard {...c} key={c.id} />
))}
</div>
</div>
</>
)
}Alhoewel de contacten nu gefilterd kunnen worden, wordt het formulierelement terug leeg gemaakt als de pagina herladen wordt. Om dit probleem op te lossen kunnen we de uitgelezen queryparameter doorgeven via de defaultValue property.
const ContactsPage: FunctionComponent<ContactsPageProps> = async ({searchParams}) => {
const {contactName} = await searchParams
const contacts = await getContacts(contactName ?? '')
return (
<>
<div className="container mx-auto p-4 grid">
<div>
<div className="mb-6">
<PageTitle>Favorites</PageTitle>
</div>
<div className="flex flex-row gap-4">
<Suspense fallback={<FavoriteSkeleton />}>
<FavoritesList />
</Suspense>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-6">
<PageTitle>My contacts</PageTitle>
<Button>
<Plus /> Add New Contact
</Button>
</div>
<form method="get">
<Input placeholder="Search your contacts" name="contactName"
defaultValue={contactName} />
</form>
{contacts.map(c => (
<ContactCard {...c} key={c.id} />
))}
</div>
</div>
</>
)
}Links
De "+ Add New Contact" knop doet nog niets. Door hier een Link component rond te zetten, kunnen we de gebruiker doorsturen naar de /contacts/new route. Net zoals in de frontend-cursus, mogen we ook hier geen gebruik maken van anchor tags.
const ContactsPage: FunctionComponent<ContactsPageProps> = async ({searchParams}) => {
const {contactName} = await searchParams
const contacts = await getContacts(contactName ?? '')
return (
<>
<div className="container mx-auto p-4 grid">
<div>
<div className="mb-6">
<PageTitle>Favorites</PageTitle>
</div>
<div className="flex flex-row gap-4">
<Suspense fallback={<FavoriteSkeleton />}>
<FavoritesList />
</Suspense>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-6">
<PageTitle>My contacts</PageTitle>
<Link href="/contacts/new">
<Button>
<Plus /> Add New Contact
</Button>
</Link>
</div>
<form method="get">
<Input placeholder="Search your contacts" name="contactName"
defaultValue={contactName} />
</form>
{contacts.map(c => (
<ContactCard {...c} key={c.id} />
))}
</div>
</div>
</>
)
}Client Components
De create pagina kan nu wel bezocht worden, maar produceert onderstaande foutmelding:
Zoals de foutmelding aangeeft moeten we een client component gebruiken.
Begrip: Client Components
Elke component die event handlers of een hook bevat moet aangeduid worden als een client component. Ondanks de naam wordt een client component nog steeds op de server gerenderd, het verschil met een server component is dat een client component gehydrateerd wordt op de client en een server component niet.
Een component kan omgevormd worden tot een client component door bovenaan, in het bestand dat de component definieert, het 'use client' directive toe te voegen. Elk kind van een client component wordt automatisch ook een client component.
Als je toch een server component wilt meegeven als kind aan een client component moet er gebruik gemaakt worden van composition.
'use client'
const ClientComponent: FunctionComponent = () => {
const [count, setCount] = useState<number>(0)
return (
<>
<button onClick={() => setCount(x => x + 1)}>{counter}</button>
</>
)
}'use client'
// ...
const Page: FunctionComponent = () => {
...
return (
<>
...
</>
)
}
export default PageNa deze aanpassen wordt de pagina wel correct geladen.

Server functions
Op de contactenpagina kan de gebruiker een contact toevoegen en verwijderen uit de lijst van favoriete contacten. Aangezien de favorieten weggeschreven moeten worden naar de database, moeten we gebruik maken van server functions.
Begrip: Server functions
Server functions zijn asynchrone functies (op de server) die automatisch geconverteerd worden naar HTTP POST endpoints. Deze endpoints worden volledig op de achtergrond aangemaakt en aangesproken, alles wat we moeten doen is de functie oproepen op de client, Next en React doen de rest.
Een functie wordt geconverteerd naar een server function door het 'use server' directive toe te voegen bovenaan de functie. Als een bestand niets anders bevat dan server functions, dan kan je de hele file markeren door 'use server' toe te voegen op de eerste lijn van het bestand.
Een server functions kan een serialiseerbaar JavaScript object teruggeven (inclusief React componenten) of kan gebruikt worden om de gebruiker te redirecten of om bepaalde layouts of pagina's te refreshen.
export async function someServerFunction() {
'use server'
// Doe iets op de server.
}'use server'
export async function someServerFunction() {
// Doe iets op de server.
}
export async function someOtherServerFunction() {
// Doe iets op de server.
}
export async function aThridServerFunction() {
// Doe iets op de server.
}:::
De startbestanden bevatten reeds het skelet van enkele server functions. Deze file moet nog gemarkeerd worden met het 'use server' directive. Daarnaast moet een DAL-functie opgeroepen worden die de database aanspreekt en de nodige wijzigingen doorvoert.
Merk op dat we een object meegeven als parameter aan de toggleFavoriteServerFunction functie en geen string. We doen dit om in een latere les op een eenvoudige en eenvormige manier validatie toe te voegen.
Tenslotte kunnen we de toggleFavoriteServerFunction koppelen de ster knop in de ContactCard alsof we een client-side functie oproepen. Next zorgt voor de automatische koppeling tussen server en client. Merk op dat een server function enkel gebruikt kan worden in een client component.
'use server'
import {toggleFavorite} from '@/dal/contacts'
export async function createContactAction(_prevData: unknown, formData: FormData): Promise<void> {
throw new Error('Not implemented')
}
export async function deleteContactServerFunction({id}: {id: string}): Promise<void> {
throw new Error('Not implemented')
}
export async function updateContactAction(_prevData: unknown, formData: FormData): Promise<void> {
throw new Error('Not implemented')
}
export async function toggleFavoriteServerFunction({id}: {id: string}): Promise<void> {
await toggleFavorite(id)
}const ContactCard: FunctionComponent<Contact> = ({avatar, description, firstName, lastName, id, favorite}) => {
return (
<Card className="my-4 py-2">
<CardContent className="flex h-full px-2 gap-4">
<AvatarWithFallback className="h-6 w-6" avatar={avatar} firstName={firstName} lastName={lastName} />
<div className="flex-grow flex justify-between">
<div>
<CardTitle>
{firstName} {lastName}
</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
<div className="flex flex-col items-center">
<Button
onClick={() => toggleFavoriteServerFunction({id})}
variant="ghost"
size="icon"
className="h-6 w-6"
aria-label={favorite ? 'Unfavorite contact' : 'Favorite contact'}>
{favorite ? <Star className="h-4 w-4 text-yellow-500 fill-yellow-500" /> : <StarOff />}
</Button>
<Link href={`/contacts/${id}`}>
<Button variant="ghost" size="icon" aria-label="View contact details" className="h-6 w-6">
<ChevronRight size={30} className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
)
}Revalidate
Zoals bovenstaande video toont, kan een contact nu wel toegevoegd worden aan de favorieten of er terug uit verwijderd worden, maar moet de pagina nog manueel herladen worden.
Begrip: Revalidate path
Next houdt een cache bij van de pagina's die al bezocht zijn door de gebruiker tijdens de actieve sessie. Na een mutation is het mogelijk dat de data op één of meerdere pagina's verouderd is en opnieuw opgehaald moet worden.
Via de revalidatePath functie kunnen we aangeven dat één of meer paden op de website gerevalideerd moeten worden.
De functie heeft twee parameters. De eerste en enige verplichte parameter is een string die het pad aangeeft dat gerevalideerd moet worden. De tweede en optionele parameter heeft twee mogelijke waarden, 'page' en 'layout', die respectievelijk aangeven of je een pagina of layout wilt revalideren. Als je een layout revalideert worden alle kinderen ook gerevalideerd op het volgende verzoek naar die URL. Als je een pagina revalideert wordt alleen die pagina gerevalideerd en blijft de omringende layout ongewijzigd.
import {revalidatePath} from 'next/cache'
export async function action() {
'use server'
// Doe iets op de server
revalidatePath('/profile')
}
export async function action2() {
'use server'
// Doe iets op de server
// Revalideer alle pagina's in de webapplicatie.
revalidatePath('/', 'layout')
}Nadat we de revalidatePath functie toevoegen, moeten we de pagina niet meer manueel herladen.
'use server'
import {toggleFavorite} from '@/dal/contacts'
import {revalidatePath} from 'next/cache'
export async function createContactAction(_prevData: unknown, formData: FormData): Promise<void> {
throw new Error('Not implemented')
}
export async function deleteContactServerFunction({id}: {id: string}): Promise<void> {
throw new Error('Not implemented')
}
export async function updateContactAction(_prevData: unknown, formData: FormData): Promise<void> {
throw new Error('Not implemented')
}
export async function toggleFavoriteServerFunction({id}: {id: string}): Promise<void> {
await toggleFavorite(id)
revalidatePath('/contacts')
}Server Actions
Begrip: Server actions
Server actions zijn server functions die bedoeld zijn om aan een formulier gekoppeld te worden via het action attribuut.
Er zijn twee soorten server actions te onderscheiden. De eerste soort heeft onderstaande structuur en kan rechtstreeks aan een formulier gekoppeld worden via het action attribuut.
(formData: FormData) => Promise<T>We gebruiken de eerste vorm niet in deze cursus. De tweede vorm heeft onderstaande signatuur en is bedoeld om via de useActionState hook aan formulieren te koppelen.
<T,>(prevState: T, formData: FormData) => Promise<T>De prevState parameter moet van hetzelfde type zijn als de return waarde van de actie en wordt gebruikt als we een formulier verschillende keren achter elkaar inzenden.
De formData parameter is een object dat alle waarden van het formulier bevat, dit object wordt automatisch opgebouwd door de browser als een formulier ingezonden wordt (dit is een standaard feature van HTML). Elk formulier element met een name attribuut wordt in dit object opgenomen (onder de naam die meegegeven is aan het name attribuut).
Om een contact aan te maken moeten we nog een server action schrijven, voorlopig gebruiken we unknown als datatype voor de _prevData parameter, in een volgende les breiden we dit uit met een zinvoller type.
Om de ingestuurde data (van het type FormData) te converteren naar een JavaScript object gebruiken we de convertFormData[4] functie uit de startbestanden. Voorlopig valideren we ingezonden data nog niet, ook dit wordt behandeld in een volgende les.
'use server'
import {createContact, toggleFavorite} from '@/dal/contacts'
import {revalidatePath} from 'next/cache'
import {convertFormData} from '@/lib/convertFormData'
import type {Contact} from '@/models/contact'
export async function createContactAction(_prevData: unknown, formData: FormData): Promise<void> {
const contact = convertFormData<Contact>(formData)
await createContact(contact)
}
export async function deleteContactServerFunction({id}: {id: string}): Promise<void> {
throw new Error('Not implemented')
}
export async function updateContactAction(_prevData: unknown, formData: FormData): Promise<void> {
throw new Error('Not implemented')
}
export async function toggleFavoriteServerFunction({id}: {id: string}): Promise<void> {
await toggleFavorite(id)
revalidatePath('/contacts')
}Om de server action te koppelen aan het formulier moet een name property toegevoegd worden aan elk formulierelement.
const Page: FunctionComponent = () => {
const [contactInfo, setContactInfo, updateContactInfoItem] = useArrayState<{type: string; value: string}>([
{type: '', value: ''},
])
return (
<>
<PageTitle>New Contact</PageTitle>
<form className="mt-8 space-y-4">
<div className="flex gap-4">
<div className="flex flex-col flex-grow gap-2">
<Label htmlFor="firstName">Firstname</Label>
<Input name="firstName" id="firstName" type="text" placeholder="John" />
</div>
<div className="flex flex-col flex-grow gap-2">
<Label htmlFor="lastName">Lastname</Label>
<Input name="lastName" id="lastName" type="text" placeholder="Doe" />
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Contactinfo</Label>
{contactInfo.map(({type, value}, i) => (
<div className="flex gap-2" key={i}>
<Input
id={`contactInfo.${i}.type`}
name={`contactInfo.${i}.type`}
type="text"
className="flex-grow"
value={type}
onChange={evt => updateContactInfoItem(i, {type: evt.target.value, value})}
placeholder="Contact type (e.g. email, phone)"
/>
<Input
id={`contactInfo.${i}.value`}
name={`contactInfo.${i}.value`}
type="text"
className="flex-grow"
value={value}
onChange={evt => updateContactInfoItem(i, {type, value: evt.target.value})}
placeholder="Contact info"
/>
</div>
))}
<LinedCircleIconButton
Icon={Plus}
disabled={contactInfo?.at(-1)?.value === '' || contactInfo?.at(-1)?.type === ''}
onClick={() => setContactInfo(old => [...old, {type: '', value: ''}])}
/>
</div>
<div className="flex justify-end mb-2">
<SubmitButtonWithLoading loadingText="Creating contact" text="Create" />
</div>
</form>
<Link href="/contacts">
<Button variant="destructive" className="w-full">
Cancel
</Button>
</Link>
</>
)
}useActionState
Vervolgens moeten we de server action koppelen via de useActionState hook en het action attribuut van het formulier.
Begrip: useActionState
De useActionState hook wordt gebruikt om server actions te koppelen aan formulieren en het resultaat van de action te verwerken.
Deze hook neemt een server action en een initiële state als argument. Het type van de initiële state moet overeenkomen met het type van de return waarde van de form action. De waarde van de initiële state (het tweede argument van de useActionState hook) wordt, via de prevState parameter meegegeven aan de form action. Dit gebeurt natuurlijk enkel de eerste keer dat je de form action oproept want daarna wordt de initiële state overschreven door de returnwaarde van de form action.
De hook geeft een triple terug:
- Het eerste element bevat de huidige state van de form action. Als de action nog nooit ingezonden is, is dit de initiële state. Als de action al wel ingezonden is, is dit de return waarde van de form action.
- Het tweede element is een wrapper rond de form action die doorgegeven kan worden aan de action property van een formulier.
- De laatste waarde is een boolean die aangeeft of de form action momenteel uitgevoerd wordt.
import {FunctionalComponent} from 'react'
import {useActionState} from 'react';
async function increment(previousState: number, formData: FormData): Promise<number> {
'use server'
return previousState + formData.get('incrementCount')
}
const formExample: FunctionComponent = () => {
const [number, formAction, isPending] = useActionState(increment, 0);
return (
<>
<h1>The form has been submitted {number} times</h1>
<form action={formAction}>
<input name="incermentCount" type="number"/>
<button type="submit" disabled={isPending}>Increment</button>
</form>
</>
)
}import {useActionState} from 'react'
const Page: FunctionComponent = () => {
const [contactInfo, setContactInfo, updateContactInfoItem] = useArrayState<{type: string; value: string}>([
{type: '', value: ''},
])
const [, createContact] = useActionState(createContactAction, undefined)
return (
<>
<PageTitle>New Contact</PageTitle>
<form className="mt-8 space-y-4" action={createContact}>
...
</form>
<Link href="/contacts">
<Button variant="destructive" className="w-full">
Cancel
</Button>
</Link>
</>
)
}Redirect
Na het aanmaken van nieuw contact moet de gebruiker teruggebracht worden naar de overzichtspagina, hiervoor gebruiken we de redirect functie.
Begrip: Redirect
De redirect functie wordt gebruikt om de gebruiker door te sturen naar een andere pagina en kan gebruikt worden in een server component, route handler of server action.
Let op, er zijn verschillende versies van de redirect functie, gebruik de import uit next/navigation.
De redirect functie werkt intern door een error te gooien, je mag deze functie dus nooit in een try-catch gebruiken.
import { redirect } from 'next/navigation'
export async function action() {
'use server'
// Doe iets op de server
redirect('/login')
}import { redirect } from 'next/navigation'
const ServerComponent: FunctionComponent = async () => {
const profile = await fetchProfile()
if (!profile) {
redirect('/login')
}
return <></>
}:::
'use server'
import {createContact, toggleFavorite} from '@/dal/contacts'
import {revalidatePath} from 'next/cache'
import {convertFormData} from '@/lib/convertFormData'
import type {Contact} from '@/models/contact'
import {redirect} from 'next/navigation'
export async function createContactAction(_prevData: unknown, formData: FormData): Promise<void> {
const contact = convertFormData<Contact>(formData)
await createContact(contact)
redirect('/contacts')
}
export async function deleteContactServerFunction({id}: {id: string}): Promise<void> {
throw new Error('Not implemented')
}
export async function updateContactAction(_prevData: unknown, formData: FormData): Promise<void> {
throw new Error('Not implemented')
}
export async function toggleFavoriteServerFunction({id}: {id: string}): Promise<void> {
await toggleFavorite(id)
revalidatePath('/contacts')
}useFormStatus
Het formulier werkt nu, maar de gebruiker krijgt geen feedback over de status van het formulier.
Begrip: useFormStatus
De useFormStatus hook kan gebruikt worden om informatie op te halen over de laatste keer dat het formulier ingezonden werd.
De hook moet als kind van een formulier opgeroepen worden, je moet dus een nieuwe component gebruiken en kunt de useFormStatus hook niet oproepen in de component waar je het formulier definieert.
import { useFormStatus } from 'react-dom'
const SubmitButton: FunctionComponent = () => {
const { pending, data, method, action } = useFormStatus()
return (
<button type="submit" disabled={pending}>Submit</button>
)
}
const Form: FunctionComponent = () => {
return (
<form action={someAction}>
<SubmitButton />
</form>
)
}Door de useFormStatus hook toe te voegen aan de SubmitButtonWithLoading component, kunnen we een visuele indicatie weergeven dat het formulier ingezonden wordt.
import {useFormStatus} from 'react-dom'
const SubmitButtonWithLoading: FunctionComponent<SubmitButtonWithLoadingProps> = ({text, loadingText}) => {
const {pending} = useFormStatus()
return (
<Button disabled={pending} type="submit" className="w-full">
{pending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{pending ? loadingText : text}
</Button>
)
}Route parameters
Momenteel kunnen we de details van een contact nog niet bekijken, de link naar de detailpagina is correct, maar het id wordt nog niet uitgelezen. De eerste stap is het aanpassen van de mappenstructuur. Als een route een parameter bevat, moet deze aangeduid worden met vierkante haken rond de naam van de omringende map.
Merk op dat we contactId gebruiken als naam en niet id. Door een prefix te gebruiken kunnen route parameters genest worden en zijn routes zoals /contacts/:contactId/messages/:messageId mogelijk.

Vervolgens gebruiken we opnieuw de properties om de parameters uit te lezen. Dit keer is de parameter niet optioneel omdat de component nooit opgeroepen wordt zonder de parameter, als de parameter ontbreekt zou Next de route niet matchen.
De detailpagina voor een contact heeft twee kindroutes. Op de eerste route wordt gewoon informatie weergegeven, op de tweede wordt een formulier getoond waarmee het contact bewerkt kan worden. De links naar deze pagina's staan in de layout van de _[contactId] map.
interface ContactDetailPageProps {
params: Promise<{
contactId: string
}>
}
const ContactDetailPage: FunctionComponent<ContactDetailPageProps> = async ({params}) => {
const {contactId} = await params
const {firstName, lastName, description, avatar, contactInfo} = await getContact(contactId)
return (
<>
<div className="flex items-center">
<AvatarWithFallback className="w-20 h-20" avatar={avatar} firstName={firstName} lastName={lastName} />
<div className="text-4xl ms-4 flex-grow">
{firstName} {lastName}
</div>
</div>
<div className="text-muted-foreground my-4">{description}</div>
<div className="text-xl">Contactinfo</div>
{contactInfo.map(({type, value}, i) => (
<div className="flex justify-between" key={i}>
<div>{type.substring(0, 1).toUpperCase() + type.slice(1)}</div>
<div>{value}</div>
</div>
))}
</>
)
}import Link from 'next/link'
interface ContactDetailPageParams extends PropsWithChildren {
params: Promise<{
contactId: string
}>
}
const DetailLayout: FunctionComponent<ContactDetailPageParams> = async ({children, params}) => {
const {contactId} = await params
return (
<>
<div className="flex flex-grow my-4">
<Link href={`/contacts/${contactId}`} className="w-full">
<Button variant="outline" className="w-full">
<PencilOff className="mr-2 h-4 w-4" />
View
</Button>
</Link>
<Link href={`/contacts/${contactId}/edit`} className="w-full">
<Button variant="outline" className="w-full">
<Pencil className="mr-2 h-4 w-4" />
Edit
</Button>
</Link>
</div>
{children}
</>
)
}useTransition
De delete-knop en het formulier zijn reeds gekoppeld in de startbestanden, de bijhorende server function en action moeten nog geïmplementeerd worden.
export async function deleteContactServerFunction({id}: {id: string}): Promise<void> {
await deleteContact(id)
redirect('/contacts')
}
export async function updateContactAction(_prevData: unknown, formData: FormData): Promise<void> {
const updatedContact = convertFormData<Contact>(formData)
await updateContact(updatedContact)
revalidatePath(`/contacts/${updatedContact.id}`)
}Alhoewel het bovenstaande werkt, kunnen we dit nog verbeteren door gebruik te maken van de useTransition hook.
Begrip: useTransition
De useTransition wordt gebruikt om een server function op te roepen.
const [isPending, startTansition] = useTransition()De isPending variabele geeft aan of de server action nog bezig is, de startTransition functie neemt een server function als argument en roept deze op. Als je verschillende transities start voordat de eerste afgerond is, worden de state updated die het gevolg zijn van de transitie geannuleerd. Pas als de laatste transitie afgerond is, worden de state updates doorgevoerd.
const ContactDetailForm: FunctionComponent<Contact> = contact => {
// ...
const [pending, startTransition] = useTransition()
return (
<form action={updateContact}>
...
<div className="flex flex-col gap-4 mt-4">
<SubmitButtonWithLoading loadingText="Updating contact..." text="Update contact" />
<Button
variant="destructive"
className="w-full"
disabled={pending}
onClick={evt => {
// Prevent the form from submitting.
evt.preventDefault()
startTransition(() => deleteContactServerFunction({id: contact.id}))
}}>
{pending && <Loader2 className="animate-spin" />} Delete contact
</Button>
</div>
</form>
)
}Logging
Om een overzicht te krijgen van de pagina's die het populairst zijn, bijna niet bezocht worden, pagina's die traag laden, ... en om problemen te kunnen oplossen is logging cruciaal.
We voegen eenvoudige logging toe die de start en het einde van elk request uitprint, in de volgende lessen voegen we ook logging toe voor fouten in het systeem, authenticatie, ... Hiervoor gebruiken we pino. Om tijdens development duidelijke logs te zien, installeren we ook pino-pretty.
Vervolgens passen we het pnpm dev commando aan zodat de logging uitvoer doorgegeven wordt aan pino-pretty.
{
"name": "backend_lecture1_example",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev | pino-pretty",
"build": "next build",
"start": "next start",
"lint": "eslint ./src",
"lint-fix": "eslint ./src --fix",
"format": "prettier --write ./src"
},
...
}Info
In deze lessenreeks loggen we enkel naar de terminal, in de praktijk moet er natuurlijk naar bestanden gelogd worden. We verwijzen de geïntereseerde lezer naar de documentatie.
Pino client
De Pino client moet geïnitialiseerd worden en ondersteund verschillende configuratieopties. Voorlopig configureren we enkel het loglevel, i.e. hoe gedetailleerd de logs moeten zijn.
Begrip: Logging Levels
Logging gebeurd aan de hand van verschillende niveaus. Pino ondersteund standaard onderstaande log-levels:
fatal: Een kritieke fout waardoor de applicatie onmiddellijk stopt of onbruikbaar wordt.error: Ernstige fouten die een functie of proces laten mislukken, maar de applicatie kan meestal blijven draaien.warn: Waarschuwingen voor mogelijke problemen of ongewenst gedrag, maar niet direct fataal.info: Algemene informatie over de normale werking van de applicatie (bijv. een succesvolle start).debug: Gedetailleerde meldingen bedoeld voor ontwikkelaars om problemen te analyseren.trace: Zeer uitgebreide logging die elke stap of actie bijhoudt, vooral nuttig bij diepgaand onderzoek.silent: Er wordt niets gelogd.
Standaard wordt het info niveau gebruikt, via environment variables[5] kan het standaard niveau overschreven worden. Hiervoor maken we, net als in de frontend cursus, een .env bestand aan.
De client aanmaken is nieuw moeilijk, maar de naïve aanpak kan traag worden tijdens productie. Omwille van de manier waarop hot module reloading (HMR) werkt, kan het zijn dat er verschillende instanties van de logger aangemaakt worden. De applicatie start namelijk niet constant opnieuw op bij elke wijziging, maar herlaad bepaalde stuken.
Om te garanderen dat er slechts één connectie gebruikt wordt, maken we een aparte file aan waarin we de client instantiëren en, in een development omgeving, cachen in het globale object dat beschikbaar blijft zolang de applicatie blijft draaien. Het global object wordt nooit aangepast tijdens HMR en zo wordt de client gecached en hergebruikt. In een productieomgeving is dit niet nodig, de applicatie start één keer en blijft draaien, de client wordt één keer geëxporteerd en gebruikt doorheen de rest van de applicatie
import pino, {type Logger} from 'pino'
const globalForLogger = globalThis as unknown as {logger: Logger}
export const logger =
globalForLogger.logger ||
pino({
level: process.env.PINO_LOG_LEVEL || 'info',
})
if (process.env.NODE_ENV !== 'production') globalForLogger.logger = loggerPINO_LOG_LEVEL=infoRequest Id
Alhoewel we een log statement kunnen toevoegen aan elke server function, geeft dit niet noodzakelijk genoeg informatie. Er zijn heel wat stappen waar er iets fout kan gaan, logs zijn pas nuttig als de logs in de verschillende onderdelen samengevoegd kunnen worden. Om dit probleem op te lossen voegen we een id toe aan elk request, daarnaast kan het ook handig zijn als we het pad en HTTP methode toevoegen aan het log statement.
Aangezien dit voor elk request moet gebeuren, voegen we deze configuratie toe in een proxy.
Begrip: Proxy
Proxy functies zijn functies die toegang hebben tot het request en response object en dit op een bepaalde manier manipuleren. In Next wordt de proxy uitgevoerd voor elk request, zowel voor pagina's in de applicatie, als voor statische files en API-routes.
Middleware moet gedefinieerd worden in /src/proxy.ts en enkel in deze file. Als je een complexe proxy functies hebt, en deze wilt opsplitsen in verschillende files, moet je de code importeren en oproepen in /src/proxy.ts als onderdeel van de proxy functie.
import {NextResponse} from 'next/server'
import type {NextRequest} from 'next/server'
export function proxy(request: NextRequest): NextResponse {
// Nieuw response object aanmaken.
const response = NextResponse.next()
// Doe verder niet voor statisch files die intern door Next gebruikt worden.
if (request.nextUrl.pathname.startsWith('/_next')) return response
// Algemene proxy code
if (request.nextUrl.pathname.startsWith('/about')) {
// Conditionele proxy code
}
// Geef het response object terug, inclusief eventuele modificaties.
return response
}Aangezien proxy uitgevoerd wordt in een ander proces dan de hoofdapplicatie, kunnen we variabelen zoals het requestId niet zomaar doorgeven tussen proxy en hoofdapplicatie. We lossen dit probleem op door het requestId toe te voegen aan de headers.
Vervolgens schrijven we een functie die een logger teruggeeft waarin het requestId, pad en de methode toegevoegd worden. Om de logger ook buiten een request te kunnen gebruiken, bijvoorbeeld voor websocket verbindingen die langdurig open blijven staan, voegen we een controle toe aan de logger die garandeert dat de getLogger functie ook werkt buiten de scope van een request (wanneer headers niet gedefinieerd is).
Tenslotte voegen het requestId toe als een cookie, op deze manier kan het id weergegeven worden in de error component. Zo kan een gebruiker die een probleem heeft het requestId doorgeven aan support en kunnen de relevante logs snel opgezocht worden. Merk op dat de httpOnly vlag op false gezet wordt, dit is nodig om het cookie uit te lezen via JavaScript (op de client).
Om het onnodig uitvoeren van de proxy functie te vermijden, stoppen we deze als er naar interne _next files of naar bestanden met een assets (bestanden die eindigen op een extensie) gevraagd wordt.
import type {NextRequest} from 'next/server'
import {NextResponse} from 'next/server'
import {cookies} from 'next/headers'
export async function proxy(request: NextRequest): Promise<NextResponse> {
const response = NextResponse.next()
// Skip internal Next files and any requests for static assets (paths that end with an extension).
if (request.nextUrl.pathname.startsWith('/_next') || request.nextUrl.pathname.match(/.*\.[^.]+$/)) return response
const awaitedCookies = await cookies()
const requestId = crypto.randomUUID()
response.headers.set('x-request-id', requestId)
response.headers.set('x-request-path', request.nextUrl.pathname)
response.headers.set('x-request-method', request.method)
awaitedCookies.set({
name: 'requestId',
value: requestId,
httpOnly: false,
})
return response
}import pino, {type Logger} from 'pino'
import {headers} from 'next/headers'
const globalForLogger = globalThis as unknown as {logger: Logger}
export const logger =
globalForLogger.logger ||
pino({
level: process.env.PINO_LOG_LEVEL || 'info',
})
if (process.env.NODE_ENV !== 'production') globalForLogger.logger = logger
export async function getLogger(): Promise<Logger> {
try {
const headerList = await headers()
return logger.child({
method: headerList.get('x-request-method'),
pathname: headerList.get('x-request-path'),
requestId: headerList.get('x-request-id'),
})
} catch (_error) {
logger.trace(_error)
return logger
}
}RequestId op de client
Om het request uit te lezen op de client moeten we gebruik maken van de CookieStore API. Deze API is asynchroon, maar de Error component moet een client component zijn, en dus synchroon.
Om dit probleem op te lossen bevatten de startbestanden de useCookie hook die de CookieStore uitleest via een useEffect.
const Error: FunctionComponent = () => {
const requestId = useCookie('requestId')
return (
<div className="min-h-screen w-full flex items-center justify-center ">
<Card className="max-w-2xl w-full shadow-lg ring-1 ring-slate-200">
<CardContent className="p-8 flex flex-col md:flex-row items-center gap-6">
<motion.div>
...
</motion.div>
<div className="flex-1 text-center md:text-left">
...
<p className="mt-4 text-xs text-slate-400">If the problem persists, contact support or try again later.</p>
<p className="text-xs text-slate-400">RequestID: {requestId}</p>
</div>
</CardContent>
</Card>
</div>
)
}Als we de detailpagina van een contact bezoeken, maar het id aanpassen in de adresbalk, krijgen we nu onderstaande foutboodschap.

Logging in server functions
Tenslotte voegen we eenvoudige logging statements toe aan de server functions, de komende lessen breiden we deze log statements uit.
'use server'
import {createContact, deleteContact, toggleFavorite, updateContact} from '@/dal/contacts'
import {revalidatePath} from 'next/cache'
import type {Contact} from '@/models/contact'
import {convertFormData} from '@/lib/convertFormData'
import {redirect} from 'next/navigation'
import {getLogger} from '@/lib/logger'
export async function createContactAction(_prevData: unknown, formData: FormData): Promise<void> {
const logger = await getLogger()
logger.info('createContactAction called')
const contact = convertFormData<Contact>(formData)
await createContact(contact)
logger.info('createContactAction completed successfully')
redirect('/contacts')
}
export async function deleteContactServerFunction({id}: {id: string}): Promise<void> {
const logger = await getLogger()
logger.info('deleteContactServerFunction called')
await deleteContact(id)
logger.info('deleteContactServerFunction completed successfully')
redirect('/contacts')
}
export async function updateContactAction(_prevData: unknown, formData: FormData): Promise<void> {
const logger = await getLogger()
logger.info('updateContactAction called')
const updatedContact = convertFormData<Contact>(formData)
await updateContact(updatedContact)
logger.info('updateContactAction completed successfully')
revalidatePath(`/contacts/${updatedContact.id}`)
}
export async function toggleFavoriteServerFunction({id}: {id: string}): Promise<void> {
const logger = await getLogger()
logger.info('toggleFavoriteServerFunction called')
await toggleFavorite(id)
revalidatePath('/contacts')
logger.info('toggleFavoriteServerFunction completed successfully')
}[10:54:57.034] INFO (13014): createContactAction called
method: "POST"
pathname: "/contacts/new"
requestId: "1f67dec0-4f80-44b1-b191-01524e1a9420"
[10:54:57.036] INFO (13014): createContactAction completed successfully
method: "POST"
pathname: "/contacts/new"
requestId: "1f67dec0-4f80-44b1-b191-01524e1a9420"
[10:55:02.308] INFO (13014): toggleFavoriteServerFunction called
method: "POST"
pathname: "/contacts"
requestId: "eb3cc9c2-ed4a-4fd7-a4d2-d185442f1ca1"
[10:55:02.309] INFO (13014): toggleFavoriteServerFunction completed successfully
method: "POST"
pathname: "/contacts"
requestId: "eb3cc9c2-ed4a-4fd7-a4d2-d185442f1ca1"
[10:55:14.865] INFO (13014): updateContactAction called
method: "POST"
pathname: "/contacts/03c51489-1489-446e-a41d-6d6f25d47d9f/edit"
requestId: "3b93438e-e035-44c0-b441-7d10d517144e"
[10:55:14.867] INFO (13014): updateContactAction completed successfully
method: "POST"
pathname: "/contacts/03c51489-1489-446e-a41d-6d6f25d47d9f/edit"
requestId: "3b93438e-e035-44c0-b441-7d10d517144e"
[10:55:17.880] INFO (13014): deleteContactServerFunction called
method: "POST"
pathname: "/contacts/03c51489-1489-446e-a41d-6d6f25d47d9f/edit"
requestId: "5c6fe45e-bc59-41d6-9e7b-7327225d8d71"
[10:55:17.881] INFO (13014): deleteContactServerFunction completed successfully
method: "POST"
pathname: "/contacts/03c51489-1489-446e-a41d-6d6f25d47d9f/edit"
requestId: "5c6fe45e-bc59-41d6-9e7b-7327225d8d71"Voorbeeldcode
Uitgewerkt lesvoorbeeld met commentaar
First Contentful Paint (FCP) is een metric die aangeeft wanneer het eerste element zichtbaar is voor de gebruiker. ↩︎
Voorbeelden van klassieke server-side rendered MPA (multi-page app) frameworks zijn ASP.net, Django, Laravel, Ruby on Rails ... ↩︎
Sinds React 19 kan je tags als
<title>en<meta>toevoegen aan de head van een pagina, ook in een SPA. Het verschil met Next is dat dit in een SPA at-runtime gebeurd en dus moeilijker te indexeren zijn door zoekmachines. Als je een SPA goed wil indexeren, moet je gebruik maken van een pre-rendering service zoals Prerender.io. ↩︎De implementatie van de convertFormData functie valt buiten de scope van deze les, als je hierin geïnteresseerd bent verwijzen we je naar de appendix. ↩︎
In productie is het meestal een goed idee om de mogelijkheid te voorzien om deze environment variable te overschrijven via een admin pagina of iets soortgelijks. Als we enkel steunen op de environment variable, moet de applicatie opnieuw opgestart worden als het loglevel gewijzigd wordt, wat nadelig kan zijn voor gebruikers (zeker als het loglevel al verhoogt moet worden omdat er een probleem is). ↩︎