2 février 2024
Intégration de App Router au sein d'une librairie Next.JS
14 minutes de lecture
Next-Admin est une librairie permettant d'intégrer une interface d'administration liée à l'ORM Prisma au sein d'une application Next.JS. Cette librairie a été développée à l'époque où la norme était d'utiliser le Page Router.
Avec le récent passage en stable de App Router, il s'est avéré nécessaire de mettre à jour Next-Admin pour intégrer un support de ce routeur. Retour en détail sur les étapes de cette intégration.
App Router vs Page Router
Tout d'abord il faut bien comprendre quelles sont les principales différences entre les deux routeurs afin d'en tirer les impacts qui en découlent pour Next-Admin.
Le point commun de ces deux routeurs est qu'ils utilisent l'arborescence des fichiers afin de construire les URL de nos pages. Ainsi, le fichier pages/user/[id].js
permettra d'accéder à la page /user/test
.
La différence majeure qui va nous intéresser dans le cas de Next-Admin est la manière de récupérer les données côté serveur, provenant par exemple d'une base de données.
Avec Page Router, on utilise getServerSideProps
. Cette fonction nous permet d'exécuter du code côté serveur et doit renvoyer des props qui seront ensuite passées à notre composant de page. Etant donné que nous avons accès aux objets de requête et de réponse dans cette fonction, il est possible de faire en sorte que cette fonction agisse elle-même comme un routeur qui peut renvoyer des données différentes selon par exemple la méthode utilisée (POST, GET, etc) pour accéder à la page. C'est exactement ce qui est fait dans Next-Admin.
App Router adopte une approche différente étant donné que nos pages peuvent être des Server Components. Ce sont des composants asynchrones dont le rendu s'effectue côté serveur. Contrairement au Page Router, nous n'avons aucun accès aux objets de requête et de réponse et il nous est donc impossible d'utiliser un routeur comme nous le faisons avec Page Router. La récupération des différentes données s'effectue directement dans le corps de notre composant de page.
Fonctionnement initial
Pour comprendre les différentes étapes de l'intégration, il est nécessaire de comprendre en premier lieu comment Next-Admin s'intègre avec le Page Router.
Le fonctionnement est assez simple. Dans la fonction getServerSideProps
de notre page (qui utilise la fonctionnalité de catch-all de Next.JS), on exécute un routeur fournit par la librairie. Ce routeur utilise next-connect
afin de récupérer les bonnes données en fonction de la méthode HTTP utilisée pour accéder à la route. Ainsi, il est possible de déterminer selon l'URL si l'on se trouve sur une page de listing (ex: /admin/users
) ou sur un formulaire (ex: /admin/users/1
), ou encore si l'on est en train de soumettre un formulaire dans le cas où on accède à notre page via une méthode POST. On passe ensuite les différentes props calculées par le routeur à notre composant de page, ainsi que les options pour gérer le formattage de certains champs, et le tour est joué. Concrètement, ça donne ça:
import { GetServerSideProps, GetServerSidePropsResult } from 'next'
import { NextAdmin, AdminComponentProps } from '@premieroctet/next-admin'
import schema from './../../prisma/json-schema/json-schema.json' // import the json-schema.json file
import '@premieroctet/next-admin/dist/styles.css'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const options = {
basePath: '/admin',
model: {
User: {
list: {
display: ['id', 'name', 'email', 'posts', 'role', 'birthDate'],
search: ['name', 'email'],
fields: {
role: {
formatter: (role) => {
return <strong>{role.toString()}</strong>
},
},
birthDate: {
formatter: (date) => {
return new Date(date as unknown as string)?.toLocaleString().split(' ')[0]
},
},
},
},
},
},
}
export default function Admin(props: AdminComponentProps) {
return <NextAdmin {...props} options={options} />
}
export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
const { nextAdminRouter } = await import('@premieroctet/next-admin/dist/router')
const adminRouter = await nextAdminRouter(prisma, schema, options)
return adminRouter.run(req, res) as Promise<GetServerSidePropsResult<{ [key: string]: any }>>
}
App Router
Maintenant pour le fonctionnement avec App Router, il va falloir faire preuve de malice étant donné que contrairement au Page Router, il nous est impossible d'accéder à la méthode HTTP utilisée. Cela va rendre la tâche plus complexe pour la soumission de formulaire.
Listes et affichage du formulaire
Pour le moment, concentrons-nous sur le fonctionnement le plus simple qui est la récupération des données. L'idée est de créer une fonction équivalente à ce qui se produit dans notre implémentation de next-connect nous permettant de récupérer les props nécessaire pour notre composant de page. Dans un premier temps, cette fonction doit recevoir les paramètres suivants:
- les paramètres de notre route catch-all, sous forme d'un array. Ceux-ci représenteront notre URL
- les search params de notre URL, sous forme d'un objet
- les mêmes données que l'on reçoit pour le page router, à savoir le client Prisma, le schema JSON et les options Next-Admin
Ce qui donne cette première ébauche:
export async function getPropsFromParams({
params,
searchParams,
options,
schema,
prisma,
}: GetPropsFromParamsParams): Promise<
AdminComponentProps | Omit<AdminComponentProps, 'dmmfSchema' | 'schema' | 'resource'>
> {
const resources = getResources(options)
const defaultProps = {
resources,
basePath: options.basePath,
isAppDir: true,
message,
}
if (!params) return defaultProps
const resource = getResourceFromParams(params, resources)
if (!resource) return defaultProps
switch (params.length) {
case 1: {
schema = await fillRelationInSchema(schema, prisma, resource, searchParams, options)
const { data, total, error } = await getMappedDataList(
prisma,
resource,
options,
new URLSearchParams(qs.stringify(searchParams))
)
return {
...defaultProps,
resource,
data,
total,
error,
schema,
}
}
case 2: {
const resourceId = getResourceIdFromParam(params[1], resource)
const model = getPrismaModelForResource(resource)
let selectedFields = model?.fields.reduce(
(acc, field) => {
// @ts-expect-error
acc[field.name] = true
return acc
},
{ id: true } as Select<typeof resource>
)
const dmmfSchema = getPrismaModelForResource(resource)
if (resourceId !== undefined) {
const edit = options?.model?.[resource]?.edit as EditOptions<typeof resource>
const editDisplayedKeys = edit && edit.display
const editSelect = editDisplayedKeys?.reduce(
(acc, column) => {
acc[column] = true
return acc
},
{ id: true } as Select<typeof resource>
)
selectedFields = editSelect ?? selectedFields
// @ts-expect-error
let data = await prisma[resource].findUniqueOrThrow({
where: { id: resourceId },
select: selectedFields,
})
schema = transformSchema(schema, resource, edit)
data = transformData(data, resource, edit)
return {
...defaultProps,
resource,
data,
schema,
dmmfSchema: dmmfSchema?.fields,
}
}
if (params[1] === 'new') {
return {
...defaultProps,
resource,
schema,
dmmfSchema: dmmfSchema?.fields,
}
}
}
default:
return defaultProps
}
}
Maintenant si l'on essaye d'utiliser cette fonction dans notre page:
export default async function AdminPage({
params,
searchParams,
}: {
params: { [key: string]: string[] }
searchParams: { [key: string]: string | string[] | undefined } | undefined
}) {
const props = await getPropsFromParams({
params: params.nextadmin,
searchParams,
options,
prisma,
schema,
})
return <NextAdmin {...props} dashboard={Dashboard} options={options} />
}
Si nos options contiennent des fonctions de formattage, on obtient une erreur de sérialisation. En fait, le listing de Next-Admin utilise les options afin de formatter certaines cellules de notre liste. Le composant de liste étant rendu côté client, la sérialisation entre le serveur et le client devient impossible, étant donné qu'une fonction n'est pas sérialisable. Dans ce cas là, une seule solution s'offre à nous: il faut formatter les cellules côté serveur uniquement lorsque l'on utilise l'App Router.
Tout d'abord, il a fallu s'assurer que les données de notre liste contenaient une propriété commune qui serait celle formatée:
export type ListDataFieldValueWithFormat = {
__nextadmin_formatted: React.ReactNode
}
export type ListDataFieldValue = ListDataFieldValueWithFormat &
(
| { type: 'scalar'; value: string | number | boolean }
| { type: 'count'; value: number }
| {
type: 'link'
value: {
label: string
url: string
}
}
| {
type: 'date'
value: Date
}
)
Là où auparavant, le type suivant était utilisé:
export type ListDataFieldValue =
| number
| string
| boolean
| { type: 'count'; value: number }
| {
type: 'link'
value: {
label: string
url: string
}
}
| {
type: 'date'
value: Date
}
On modifie notre fonction de mapping des données pour ajouter cette propriété:
const listFields = options.model?.[resource]?.list?.fields ?? {}
data.forEach((item, index) => {
Object.keys(item).forEach((key) => {
let itemValue
if (typeof item[key] === 'object' && item[key] !== null) {
switch (item[key].type) {
case 'link':
itemValue = item[key].value.label
break
case 'count':
itemValue = item[key].value
break
case 'date':
itemValue = item[key].value.toString()
break
default:
itemValue = item[key].id
break
}
item[key].__nextadmin_formatted = itemValue
} else if (isScalar(item[key]) && item[key] !== null) {
item[key] = {
type: 'scalar',
value: item[key],
__nextadmin_formatted: item[key].toString(),
}
itemValue = item[key].value
}
/**
* On formatte la donnée côté serveur uniquement si on utilise App Router
*/
if (
appDir &&
key in listFields &&
listFields[key as keyof typeof listFields]?.formatter &&
!!itemValue
) {
item[key].__nextadmin_formatted = listFields[
key as keyof typeof listFields
// @ts-expect-error
]?.formatter?.(itemValue ?? item[key], context)
} else {
data[index][key] = item[key]
}
})
})
Et voilà, notre formattage s'exécute bien côté client, et on peut maintenant se baser sur la propriété __nextadmin_formatted
pour l'affichage des données dans notre cellule:
export default function Cell({ cell }: Props) {
const { basePath } = useConfig()
const cellValue = cell?.__nextadmin_formatted
const isReactNode = (cell: ListDataFieldValue['__nextadmin_formatted']): cell is ReactNode => {
return React.isValidElement(cell)
}
if (cell && cell !== null) {
if (React.isValidElement(cellValue)) {
return cellValue
} else if (typeof cell === 'object' && !isReactNode(cellValue)) {
if (cell.type === 'link') {
return (
<Link
onClick={(e) => e.stopPropagation()}
href={`${basePath}/${cell.value.url}`}
className="hover:underline cursor-pointer text-indigo-700 hover:text-indigo-900 font-semibold"
>
{cellValue}
</Link>
)
} else if (cell.type === 'count') {
return (
<div className="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10">
<p>{cellValue}</p>
</div>
)
} else if (cell.type === 'date') {
return (
<div className="whitespace-nowrap max-w-[20ch] overflow-hidden text-ellipsis text-neutral-600">
<p>{cellValue}</p>
</div>
)
} else if (cell.type === 'scalar' && typeof cell.value === 'string') {
return (
<div className="whitespace-nowrap overflow-hidden text-ellipsis text-neutral-600">
<p>{cellValue}</p>
</div>
)
} else if (cell.type === 'scalar' && typeof cell.value === 'number') {
return (
<div className="whitespace-nowrap max-w-[20ch] overflow-hidden text-ellipsis text-neutral-600">
<p>{cellValue}</p>
</div>
)
} else if (cell.type === 'scalar' && typeof cell.value === 'boolean') {
return (
<div
className={clsx(
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium',
cell ? 'bg-indigo-50 text-indigo-500' : 'bg-neutral-50 text-neutral-600'
)}
>
<p>{cellValue}</p>
</div>
)
}
}
return <div>{JSON.stringify(cellValue)}</div>
}
return null
}
Grâce à cela, il n'est maintenant plus nécessaire de passer les options en props de notre composant NextAdmin.
Soumission de formulaire
Maintenant, seconde partie de cette intégration d'App Router, la gestion de soumission du formulaire de création / édition. Avec Page Router, le fonctionnement est assez simple, étant donné qu'il s'agit d'une soumission de formulaire classique utilisant une méthode POST, impliquant donc le rafraîchissement de la page. Cette soumission passe par le router next-connect, qui va traîter la donnée et renvoyer les bonnes props. Avec App Router, étant donné que l'on a plus la possibilité de récupérer la méthode HTTP utilisée, deux solutions s'offrent à nous:
- soit on fait en sorte que l'utilisateur crée une route API permettant de traiter la requête
- soit on utilise une server action
Nous avons opté pour la seconde solution. Dans un premier lieu, il a fallu implémenter la logique de création / édition / suppression dans une fonction qui servira de server action.
export const submitForm = async (
{ options, params, schema, prisma }: ActionFullParams,
formData: FormData
): Promise<SubmitFormResult | undefined> => {
if (!params) {
return
}
const resources = getResources(options)
const resource = getResourceFromParams(params, resources)
if (!resource) {
return
}
const resourceId = getResourceIdFromParam(params[1], resource)
const { __admin_action: action, ...formValues } = await getFormValuesFromFormData(formData)
const dmmfSchema = getPrismaModelForResource(resource)
const parsedFormData = parseFormData(formValues, dmmfSchema?.fields!)
try {
if (action === 'delete') {
if (resourceId !== undefined) {
// @ts-expect-error
await prisma[resource].delete({
where: {
id: resourceId,
},
})
}
return { deleted: true }
}
// Update
let data
const fields = options.model?.[resource]?.edit?.fields as EditFieldsOptions<typeof resource>
// Validate
validate(parsedFormData, fields)
if (resourceId !== undefined) {
// @ts-expect-error
data = await prisma[resource].update({
where: {
id: resourceId,
},
data: await formattedFormData(
formValues,
dmmfSchema?.fields!,
schema,
resource,
false,
fields
),
})
return { updated: true }
}
// Create
// @ts-expect-error
data = await prisma[resource].create({
data: await formattedFormData(
formValues,
dmmfSchema?.fields!,
schema,
resource,
true,
fields
),
})
return { created: true, createdId: data.id }
} catch (error: any) {
if (
error.constructor.name === PrismaClientValidationError.name ||
error.constructor.name === PrismaClientKnownRequestError.name ||
error.name === 'ValidationError'
) {
let data = parsedFormData
if (error.name === 'ValidationError') {
error.errors.map((error: any) => {
// @ts-expect-error
data[error.property] = formData[error.property]
})
}
return {
error: error.message,
validation: error.errors,
}
}
throw error
}
}
Côté application, il est nécessaire de créer une action qui implémente celle de la librairie:
'use server'
import { ActionParams } from '@premieroctet/next-admin'
import { submitForm } from '@premieroctet/next-admin/dist/actions'
import { prisma } from '../prisma'
import { options } from '../options'
export const submitFormAction = async (params: ActionParams, formData: FormData) => {
return submitForm({ ...params, options, prisma }, formData)
}
En théorie, tout cela pourrait être fait côté serveur, dans notre fonction de récupération des props. En pratique, cela provoque des soucis de sérialisation, un peu comme le soucis des options passées en props. C'est pour cela qu'il est nécessaire qu'une server action soit implémentée côté application. Du côté de la librairie, on effectue un binding avec les options par défaut à passer à notre action:
import { ActionParams } from '../types'
export const createBoundServerAction = (
{ params, schema }: ActionParams,
action: (params: ActionParams, formData: FormData) => Promise<any>
) => {
return action.bind(null, {
params,
schema,
})
}
// getPropsFromParams
const defaultProps = {
resources,
basePath: options.basePath,
isAppDir: true,
action: createBoundServerAction({ schema, params }, action),
message,
error: searchParams?.error as string,
}
On peut maintenant utiliser notre action dans notre composant de page:
import { NextAdmin } from '@premieroctet/next-admin'
import { getPropsFromParams } from '@premieroctet/next-admin/dist/appRouter'
import '@premieroctet/next-admin/dist/styles.css'
import Dashboard from '../../../components/Dashboard'
import { options } from '../../../options'
import { prisma } from '../../../prisma'
import schema from '../../../prisma/json-schema/json-schema.json'
import '../../../styles.css'
import { submitFormAction } from '../../../actions/nextadmin'
export default async function AdminPage({
params,
searchParams,
}: {
params: { [key: string]: string[] }
searchParams: { [key: string]: string | string[] | undefined } | undefined
}) {
const props = await getPropsFromParams({
params: params.nextadmin,
searchParams,
options,
prisma,
schema,
action: submitFormAction,
})
return <NextAdmin {...props} dashboard={Dashboard} />
}
Du côté de la librairie dans notre formulaire, il faut maintenant gérer le cas où l'on passe par une server action:
const onSubmit = async (formData: FormData) => {
if (action) {
const result = await action(formData)
/*
* On reproduit le comportement que l'on a avec le Page Router
* en cas de validation ou d'erreur
*/
if (result?.validation) {
setValidation(result.validation)
}
if (result?.deleted) {
return router.replace({
pathname: `${basePath}/${resource}`,
query: {
message: JSON.stringify({
type: 'success',
content: 'Deleted successfully',
}),
},
})
}
if (result?.created) {
return router.replace({
pathname: `${basePath}/${resource}/${result.createdId}`,
query: {
message: JSON.stringify({
type: 'success',
content: 'Created successfully',
}),
},
})
}
if (result?.updated) {
location.search = `?message=${encodeURIComponent(
JSON.stringify({
type: 'success',
content: 'Updated successfully',
})
)}`
}
if (result?.error) {
return router.replace({
pathname: location.pathname,
query: {
error: result.error,
},
})
}
}
}
<CustomForm
// @ts-expect-error
action={action ? onSubmit : ''}
method="post"
idPrefix=""
idSeparator=""
enctype={!action ? 'multipart/form-data' : undefined}
{...schemas}
formData={data}
validator={validator}
/>
Et voilà, on a maintenant un système de soumission de formulaire fonctionnel avec App Router et Page Router.
Gestion du router Next.JS
Certaines parties de la librairies nécessitent l'utilisation du hook useRouter
de Next.JS. C'est le cas par exemple pour la liste où l'on va effectuer une redirection vers la page d'édition lors du clic sur un élément de la liste. Pour cela, il a fallu créer un hook permettant de gérer le bon router selon la méthode de routing utilisée.
import { NextRouter, useRouter as usePageRouter } from 'next/router'
import { useRouter as useAppRouter, usePathname, useSearchParams } from 'next/navigation'
import qs from 'querystring'
import { useConfig } from '../context/ConfigContext'
type AppRouter = ReturnType<typeof useAppRouter>
type Query = Record<string, string | string[] | number | null>
type PushParams = {
pathname: string
query?: Query
}
export const useRouterInternal = () => {
const { isAppDir } = useConfig()
const router = isAppDir ? useAppRouter() : usePageRouter()
const query = isAppDir
? useSearchParams()
: new URLSearchParams(
typeof window !== 'undefined' ? location.search : qs.stringify((router as NextRouter).query)
)
const pathname = isAppDir ? usePathname() : (router as NextRouter).asPath.split('?')[0]
const push = ({ pathname, query }: PushParams) => {
if (isAppDir) {
;(router as AppRouter).push(pathname + (query ? '?' + qs.stringify(query) : ''))
} else {
;(router as NextRouter).push({ pathname, query })
}
}
const replace = ({ pathname, query }: PushParams) => {
if (isAppDir) {
;(router as AppRouter).replace(pathname + (query ? '?' + qs.stringify(query) : ''))
} else {
;(router as NextRouter).replace({ pathname, query })
}
}
const refresh = () => {
if (isAppDir) {
;(router as AppRouter).refresh()
} else {
;(router as NextRouter).replace((router as NextRouter).asPath)
}
}
const setQuery = (queryArg: Query, merge = false) => {
const currentQuery = Object.fromEntries(query)
const newQuery = merge ? { ...currentQuery, ...queryArg } : queryArg
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(newQuery)) {
if (value) {
searchParams.set(key, value as string)
}
}
location.search = searchParams.toString()
}
return {
router: { push, replace, refresh, setQuery },
query: Object.fromEntries(query),
pathname,
}
}
Conclusion
Bien qu'assez effrayante au départ, l'implémentation de la compatibilité avec App Router s'est avérée certes fastidieuse, mais assez simple de par les alternatives proposées par Next.JS, notamment avec les server actions. Cela m'a aussi personellement permis d'en apprendre un peu plus sur le fonctionnement des server components et de toutes les problématiques qui peuvent en découler. Il reste toutefois quelques points à améliorer, par exemple nous n'avons plus de barre de chargement durant les changements de routes, rendue grâce à nextjs-progressbar. Vous pouvez visiter la pull request de cette implémentation pour de plus amples détails, et de manière générale nous accueillerons avec plaisir toute contribution à la librairie.