Aller au contenu principal

Utiliser les Server Actions avec tRPC

· 9 min de lecture
Julius Marminge
tRPC Core Team Member
Traduction Bêta Non Officielle

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

note

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 install @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.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
);
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
const serverActionProcedure = t.procedure.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).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.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.next({ ctx: { user } });
});
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
return opts.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.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
const user: User | null
return opts.next({ ctx: { user } });
});
 
export const protectedAction = serverActionProcedure.use((opts) => {
if (!opts.ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
});
}
 
return opts.next({
ctx: {
...opts.ctx,
user: opts.ctx.user, // <-- ensures type is non-nullable
(property) user: User
},
});
});
server/trpc.ts
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { experimental_nextAppDirCaller } from '@trpc/server/adapters/next-app-dir';
import { currentUser } from './auth';
 
interface Meta {
span: string;
}
 
export const t = initTRPC.meta<Meta>().create();
 
export const serverActionProcedure = t.procedure
.experimental_caller(
experimental_nextAppDirCaller({
pathExtractor: ({ meta }) => (meta as Meta).span,
}),
)
.use(async (opts) => {
// Inject user into context
const user = await currentUser();
const user: User | null
return opts.next({ ctx: { user } });
});
 
export const protectedAction = serverActionProcedure.use((opts) => {
if (!opts.ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
});
}
 
return opts.next({
ctx: {
...opts.ctx,
user: opts.ctx.user, // <-- ensures type is non-nullable
(property) user: User
},
});
});

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.ts
ts
'use server';
 
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
 
export const createPost = 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;
const createPost: (input: { title: string; }) => Promise<void>
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { protectedAction } from '../server/trpc';
 
export const createPost = 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;
const createPost: (input: { title: string; }) => Promise<void>

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.tsx
tsx
'use client';
import { createPost } from '~/_actions';
export function PostForm() {
return (
<form
// Use `action` to make form progressively enhanced
action={createPost}
// `Using `onSubmit` allows building rich interactive
// forms once JavaScript has loaded
onSubmit={async (e) => {
e.preventDefault();
const title = new FormData(e.target).get('title');
// Maybe show loading toast, etc etc. Endless possibilities
await createPost({ title });
}}
>
<input type="text" name="title" />
<button type="submit">Create Post</button>
</form>
);
}
app/post-form.tsx
tsx
'use client';
import { createPost } from '~/_actions';
export function PostForm() {
return (
<form
// Use `action` to make form progressively enhanced
action={createPost}
// `Using `onSubmit` allows building rich interactive
// forms once JavaScript has loaded
onSubmit={async (e) => {
e.preventDefault();
const title = new FormData(e.target).get('title');
// Maybe show loading toast, etc etc. Endless possibilities
await 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 context
const user = await currentUser();
return opts.next({ ctx: { user } });
})
+ .use(tracing());
--- app/_actions.ts
+++ app/_actions.ts
export 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 context
const user = await currentUser();
return opts.next({ ctx: { user } });
})
+ .use(tracing());
--- app/_actions.ts
+++ app/_actions.ts
export 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.ts
ts
import { Ratelimit } from '@unkey/ratelimit';
 
export const rateLimitedAction = protectedAction.use(async (opts) => {
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
async: true,
duration: '10s',
limit: 5,
namespace: `trpc_${opts.path}`,
});
 
const ratelimit = await unkey.limit(opts.ctx.user.id);
if (!ratelimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: JSON.stringify(ratelimit),
});
}
 
return opts.next();
});
server/trpc.ts
ts
import { Ratelimit } from '@unkey/ratelimit';
 
export const rateLimitedAction = protectedAction.use(async (opts) => {
const unkey = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
async: true,
duration: '10s',
limit: 5,
namespace: `trpc_${opts.path}`,
});
 
const ratelimit = await unkey.limit(opts.ctx.user.id);
if (!ratelimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: JSON.stringify(ratelimit),
});
}
 
return opts.next();
});
app/_actions.ts
ts
'use server';
 
import { z } from 'zod';
import { rateLimitedAction } from '../server/trpc';
 
export const commentOnPost = 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.ts
ts
'use server';
 
import { z } from 'zod';
import { rateLimitedAction } from '../server/trpc';
 
export const commentOnPost = 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 !