Utiliser les Server Actions avec tRPC
Cette page a été traduite par PageTurner AI (bêta). Non approuvée officiellement par le projet. Vous avez trouvé une erreur ? Signaler un problème →
Le pattern de builder pour créer des procédures introduit dans tRPC v10 a été massivement apprécié par la communauté, et de nombreuses bibliothèques ont adopté des patterns similaires.
On voit même émerger le terme tRPC like XYZ comme preuve de la popularité croissante de ce pattern. Récemment, j'ai d'ailleurs vu quelqu'un
se demander s'il était possible d'écrire des applications CLI avec une API similaire à tRPC.
Note au passage : vous pouvez même utiliser tRPC directement pour cela. Mais ce n'est pas le sujet du jour.
Nous allons plutôt parler d'utiliser tRPC avec les Server Actions de Next.js.
Qu'est-ce qu'une Server Action ?
Au cas où vous seriez passé à côté des dernières fonctionnalités de React et Next.js, les Server Actions permettent d'écrire des fonctions classiques exécutées côté serveur, de les importer côté client et de les appeler comme si c'étaient des fonctions ordinaires. Vous pourriez penser que cela ressemble à tRPC - et vous auriez raison. Selon Dan Abramov, les Server Actions sont l'équivalent de tRPC en tant que fonctionnalité de bundler :
Et c'est tout à fait exact. Les Server Actions sont similaires à tRPC : au final, ce sont toutes deux des RPC. Toutes deux vous permettent d'écrire des fonctions backend et de les appeler avec une typage fort côté frontend, sans vous soucier de la couche réseau.
Alors où intervient tRPC ? Pourquoi aurais-je besoin à la fois de tRPC et des Server Actions ? Les Server Actions sont une primitive, et comme toute primitive, elles sont assez basiques et manquent d'aspects fondamentaux pour construire des API. Pour tout endpoint exposé sur le réseau, vous devez valider et autoriser les requêtes pour éviter les utilisations malveillantes. Comme mentionné précédemment, l'API de tRPC est appréciée par la communauté. Ne serait-il pas idéal d'utiliser tRPC pour définir des Server Actions et bénéficier de ses fonctionnalités intégrées comme la validation des entrées, l'authentification/autorisation via middlewares, la validation des sorties, les transformers de données, etc. ? Je pense que oui, alors creusons cela.
Définir des Server Actions avec tRPC
Prérequis : Pour utiliser les Server Actions, vous devez utiliser le routeur App de Next.js. De plus, toutes les fonctionnalités tRPC dont nous aurons besoin ne sont disponibles qu'à partir de tRPC v11, assurez-vous donc d'utiliser la version beta :
- npm
- yarn
- pnpm
- bun
- deno
npm install @trpc/server
yarn add @trpc/server
pnpm add @trpc/server
bun add @trpc/server
deno add npm:@trpc/server
Commençons par initialiser tRPC et définir notre procédure de base pour les Server Actions.
Nous utiliserons la méthode experimental_caller du builder de procédures, une nouvelle méthode permettant de personnaliser l'appel de la procédure lorsqu'elle est invoquée comme fonction. Nous utiliserons aussi l'adaptateur experimental_nextAppDirCaller
pour la compatibilité avec Next.js. Cet adaptateur gérera les cas où la Server Action est wrappée dans useActionState côté client,
ce qui modifie la signature d'appel de la Server Action.
Nous utiliserons également une propriété span comme métadonnée, puisqu'il n'y a pas de chemin classique comme avec un routeur (user.byId par exemple). Vous pouvez utiliser la propriété span pour différencier les procédures, par exemple pour du logging ou de l'observabilité.
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),);
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),);
Nous allons maintenant ajouter du contexte. Comme nous n'hébergerons pas de routeur via un adaptateur HTTP classique, nous n'aurons pas de contexte injecté via la méthode createContext
de l'adaptateur. À la place, nous utiliserons un middleware pour injecter notre contexte. Dans cet exemple, récupérons l'utilisateur courant depuis la session et injectons-le dans le contexte.
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});
Enfin, nous allons créer une procédure protectedAction qui protégera toute action des utilisateurs non authentifiés. Si vous avez déjà un middleware qui fait cela, vous pouvez l'utiliser. Pour cet exemple, je vais en définir un directement.
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});export constprotectedAction =serverActionProcedure .use ((opts ) => {if (!opts .ctx .user ) {throw newTRPCError ({code : 'UNAUTHORIZED',});}returnopts .next ({ctx : {...opts .ctx ,user :opts .ctx .user , // <-- ensures type is non-nullable},});});
server/trpc.tstsimport {initTRPC ,TRPCError } from '@trpc/server';import {experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';import {currentUser } from './auth';interfaceMeta {span : string;}export constt =initTRPC .meta <Meta >().create ();export constserverActionProcedure =t .procedure .experimental_caller (experimental_nextAppDirCaller ({pathExtractor : ({meta }) => (meta asMeta ).span ,}),).use (async (opts ) => {// Inject user into contextconstuser = awaitcurrentUser ();returnopts .next ({ctx : {user } });});export constprotectedAction =serverActionProcedure .use ((opts ) => {if (!opts .ctx .user ) {throw newTRPCError ({code : 'UNAUTHORIZED',});}returnopts .next ({ctx : {...opts .ctx ,user :opts .ctx .user , // <-- ensures type is non-nullable},});});
Très bien, écrivons une véritable Server Action. Créez un fichier _actions.ts, décorez-le avec la directive "use server", et définissez votre action.
app/_actions.tsts'use server';import {z } from 'zod';import {protectedAction } from '../server/trpc';export constcreatePost =protectedAction .input (z .object ({title :z .string (),}),).mutation (async (opts ) => {// Do something with the input});// Since we're using the `experimental_caller`,// our procedure is now just an ordinary function:createPost ;
app/_actions.tsts'use server';import {z } from 'zod';import {protectedAction } from '../server/trpc';export constcreatePost =protectedAction .input (z .object ({title :z .string (),}),).mutation (async (opts ) => {// Do something with the input});// Since we're using the `experimental_caller`,// our procedure is now just an ordinary function:createPost ;
Wow, c'est aussi simple de définir une Server Action protégée contre les utilisateurs non authentifiés, avec validation des entrées pour prévenir des attaques comme les injections SQL. Importons maintenant cette fonction côté client et appelons-la.
app/post-form.tsxtsx'use client';import { createPost } from '~/_actions';export function PostForm() {return (<form// Use `action` to make form progressively enhancedaction={createPost}// `Using `onSubmit` allows building rich interactive// forms once JavaScript has loadedonSubmit={async (e) => {e.preventDefault();const title = new FormData(e.target).get('title');// Maybe show loading toast, etc etc. Endless possibilitiesawait createPost({ title });}}><input type="text" name="title" /><button type="submit">Create Post</button></form>);}
app/post-form.tsxtsx'use client';import { createPost } from '~/_actions';export function PostForm() {return (<form// Use `action` to make form progressively enhancedaction={createPost}// `Using `onSubmit` allows building rich interactive// forms once JavaScript has loadedonSubmit={async (e) => {e.preventDefault();const title = new FormData(e.target).get('title');// Maybe show loading toast, etc etc. Endless possibilitiesawait createPost({ title });}}><input type="text" name="title" /><button type="submit">Create Post</button></form>);}
Aller plus loin
En utilisant les builders de tRPC et sa manière composable de définir des procédures réutilisables, nous pouvons facilement créer des Server Actions plus complexes. Voici quelques exemples :
Observabilité
Vous pouvez utiliser le plugin trpc de @baselime/node-opentelemtry pour ajouter de l'observabilité en quelques lignes de code :
diff--- server/trpc.ts+++ server/trpc.ts+ import { tracing } from '@baselime/node-opentelemetry/trpc';export const serverActionProcedure = t.procedure.experimental_caller(experimental_nextAppDirCaller({pathExtractor: (meta: Meta) => meta.span,}),).use(async (opts) => {// Inject user into contextconst user = await currentUser();return opts.next({ ctx: { user } });})+ .use(tracing());--- app/_actions.ts+++ app/_actions.tsexport const createPost = protectedAction+ .meta({ span: 'create-post' }).input(z.object({title: z.string(),}),).mutation(async (opts) => {// Do something with the input});
diff--- server/trpc.ts+++ server/trpc.ts+ import { tracing } from '@baselime/node-opentelemetry/trpc';export const serverActionProcedure = t.procedure.experimental_caller(experimental_nextAppDirCaller({pathExtractor: (meta: Meta) => meta.span,}),).use(async (opts) => {// Inject user into contextconst user = await currentUser();return opts.next({ ctx: { user } });})+ .use(tracing());--- app/_actions.ts+++ app/_actions.tsexport const createPost = protectedAction+ .meta({ span: 'create-post' }).input(z.object({title: z.string(),}),).mutation(async (opts) => {// Do something with the input});
Consultez l'intégration Baselime pour tRPC pour plus d'informations. Des patterns similaires devraient fonctionner quelle que soit votre plateforme d'observabilité.
Limitation de débit (Rate Limiting)
Vous pouvez utiliser un service comme Unkey pour limiter le débit de vos Server Actions. Voici un exemple d'action protégée utilisant Unkey pour limiter le nombre de requêtes par utilisateur :
server/trpc.tstsimport {Ratelimit } from '@unkey/ratelimit';export constrateLimitedAction =protectedAction .use (async (opts ) => {constunkey = newRatelimit ({rootKey :process .env .UNKEY_ROOT_KEY !,async : true,duration : '10s',limit : 5,namespace : `trpc_${opts .path }`,});constratelimit = awaitunkey .limit (opts .ctx .user .id );if (!ratelimit .success ) {throw newTRPCError ({code : 'TOO_MANY_REQUESTS',message :JSON .stringify (ratelimit ),});}returnopts .next ();});
server/trpc.tstsimport {Ratelimit } from '@unkey/ratelimit';export constrateLimitedAction =protectedAction .use (async (opts ) => {constunkey = newRatelimit ({rootKey :process .env .UNKEY_ROOT_KEY !,async : true,duration : '10s',limit : 5,namespace : `trpc_${opts .path }`,});constratelimit = awaitunkey .limit (opts .ctx .user .id );if (!ratelimit .success ) {throw newTRPCError ({code : 'TOO_MANY_REQUESTS',message :JSON .stringify (ratelimit ),});}returnopts .next ();});
app/_actions.tsts'use server';import {z } from 'zod';import {rateLimitedAction } from '../server/trpc';export constcommentOnPost =rateLimitedAction .input (z .object ({postId :z .string (),content :z .string (),}),).mutation (async (opts ) => {console .log (`${opts .ctx .user .name } commented on ${opts .input .postId } saying ${opts .input .content }`,);});
app/_actions.tsts'use server';import {z } from 'zod';import {rateLimitedAction } from '../server/trpc';export constcommentOnPost =rateLimitedAction .input (z .object ({postId :z .string (),content :z .string (),}),).mutation (async (opts ) => {console .log (`${opts .ctx .user .name } commented on ${opts .input .postId } saying ${opts .input .content }`,);});
Lisez-en plus sur la limitation de débit des procédures tRPC dans cet article de l'équipe Unkey.
Les possibilités sont infinies, et je parie que vous avez déjà plein d'utilitaires middleware sympas dans vos applications tRPC actuelles. Sinon, vous en trouverez sûrement sur npm que vous pouvez npm install !
Pour conclure
Les Server Actions ne sont en aucun cas une solution miracle. Pour les données plus dynamiques, vous voudrez peut-être conserver vos données dans le cache React Query côté client, et effectuer les mutations via useMutation. C'est tout à fait valable. Ces nouvelles primitives permettent aussi une adoption progressive, vous pouvez donc migrer des procédures individuelles de votre API tRPC existante vers des Server Actions là où cela a du sens. Pas besoin de tout réécrire.
En définissant vos Server Actions avec tRPC, vous pouvez réutiliser une grande partie de la logique actuelle et choisir où exposer la mutation : en tant que Server Action ou mutation traditionnelle. En tant que développeur, vous avez le pouvoir de choisir les patterns qui conviennent le mieux à votre application. Si vous n'utilisez pas encore tRPC, des packages comme next-safe-action et zsa permettent de définir des Server Actions typesafe avec validation des entrées.
Pour voir une application utilisant ce pattern, consultez Trellix tRPC, une app que j'ai récemment créée avec ces nouvelles primitives.
Qu'en pensez-vous ? Nous voulons votre feedback
Alors, qu'en pensez-vous ? Dites-le nous sur Github et aidez-nous à peaufiner ces primitives pour les stabiliser.
Il reste du travail, notamment sur la gestion des erreurs. Next.js préconise de retourner les erreurs, et nous aimerions rendre cela aussi typesafe que possible. Voyez cette PR en cours par Alex pour un aperçu des travaux en cours.
En attendant, bon code !
