AccueilClientsExpertisesBlogOpen SourceJobsContact

2 février 2024

Intégration de App Router au sein d'une librairie Next.JS

14 minutes de lecture

Intégration de App Router au sein d'une librairie Next.JS

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.

18 avenue Parmentier
75011 Paris
+33 1 43 57 39 11
hello@premieroctet.com

Suivez nos aventures

GitHub
Twitter
Flux RSS

Naviguez à vue