Hoppa till huvudinnehållet

Använda Server Actions med tRPC

· 9 minuters läsning
Julius Marminge
tRPC Core Team Member
Inofficiell Beta-översättning

Denna sida har översatts av PageTurner AI (beta). Inte officiellt godkänd av projektet. Hittade du ett fel? Rapportera problem →

Byggarmönstret för att skapa procedurer som introducerades i tRPC v10 har fått stor uppskattning från communityt, och många bibliotek har antagit liknande mönster. Det har till och med myntats en term tRPC like XYZ som bevis på mönstrets ökande popularitet. För inte så länge sedan såg jag faktiskt någon som undrade om det fanns ett sätt att skriva CLI-applikationer med ett liknande API som tRPC. Sidnotering: du kan till och med använda tRPC direkt för detta. Men det är inte det vi ska prata om idag, vi ska diskutera hur man använder tRPC med server actions från Next.js.

Vad är en server action?

Om du levt under en sten och inte hängt med i de senaste React- och Next.js-funktionerna: server actions låter dig skriva vanliga funktioner som körs på servern, importera dem på klienten och anropa dem precis som om de vore vanliga funktioner. Du kanske tycker att detta låter likt tRPC - och det stämmer. Enligt Dan Abramov är server actions "tRPC som en bundler-funktion":

Och detta är helt korrekt. Server actions liknar tRPC, i slutändan är de båda RPC:er. Båda låter dig skriva funktioner på backend och anropa dem med full typstyrning på frontend med nätverkslagret abstraherat bort.

Så var kommer tRPC in i bilden? Varför skulle jag behöva både tRPC och server actions? Server actions är en primitiv, och som alla primitiver är de ganska grundläggande och saknar därmed vissa fundamentala aspekter när det gäller att bygga API:er. För alla API-slutpunkter som exponeras över nätverket måste du validera och auktorisera förfrågningar för att säkerställa att ditt API inte missbrukas. Som tidigare nämnts uppskattas tRPC:s API av communityt, så vore det inte trevligt om vi kunde använda tRPC för att definiera server actions och utnyttja alla fantastiska funktioner som kommer inbyggda i tRPC? Tänk validering av indata, autentisering och auktorisering via middleware, validering av utdata, datatransformatorer, etc. Jag tycker det, så låt oss gräva djupare.

Definiera server actions med tRPC

notering

Förutsättningar: För att använda server actions måste du använda Next.js App Router. Dessutom är alla tRPC-funktioner vi ska använda endast tillgängliga i tRPC v11, så se till att du använder beta-releasekanalen för tRPC:

npm install @trpc/server

Låt oss börja med att initiera tRPC och definiera vår basprocedur för server actions. Vi kommer använda metoden experimental_caller i procedure-builder:en, en ny metod som låter dig anpassa hur proceduren anropas när den invokeras som en funktion. Vi kommer också använda adapter:en experimental_nextAppDirCaller för att göra den kompatibel med Next.js. Denna adapter hanterar fall där server action:en är inlindad i useActionState på klienten, vilket ändrar anropssignaturen för server action:en.

Vi kommer också använda en span-egenskap som metadata, eftersom det inte finns någon vanlig sökväg som när du använder en router (t.ex. user.byId). Du kan använda span-egenskapen för att skilja mellan procedurer, till exempel vid loggning eller observabilitet.

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,
}),
);

Därefter lägger vi till kontext. Eftersom vi inte kommer hosta en router med en vanlig HTTP-adapter kommer vi inte få någon kontext injicerad genom createContext-metoden på adapter:en. Istället använder vi en middleware för att injicera vår kontext. I detta exempel hämtar vi den aktuella användaren från sessionen och injicerar den i kontexten.

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 } });
});

Slutligen skapar vi en protectedAction-procedur som skyddar alla åtgärder från oautentiserade användare. Om du redan har en middleware som gör detta kan du använda den, men jag definierar en inline i det här exemplet.

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
},
});
});

Okej, låt oss skriva en riktig serveråtgärd. Skapa en _actions.ts-fil, dekorera den med direktivet "use server", och definiera din åtgärd.

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, så enkelt är det att definiera en serveråtgärd som är skyddad från oautentiserade användare, med indatavalidering för att skydda mot attacker som SQL-injektioner. Låt oss importera den här funktionen på klienten och anropa den.

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>
);
}

Vidareutveckling

Genom att använda tRPC:s byggstenar och dess komponerbara sätt att definiera återanvändbara procedurer kan vi enkelt skapa mer komplexa serveråtgärder. Här är några exempel:

Observabilitet

Du kan använda @baselime/node-opentelemtry:s tRPC-plugin för att lägga till observabilitet med bara några rader kod:

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
});

Kolla in Baselimes tRPC-integrering för mer information. Liknande mönster bör fungera oavsett vilken observabilitetsplattform du använder.

Begränsning av anrop (Rate Limiting)

Du kan använda en tjänst som Unkey för att begränsa antalet anrop till dina serveråtgärder. Här är ett exempel på en skyddad serveråtgärd som använder Unkey för att begränsa antalet förfrågningar per användare:

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}`,
);
});

Läs mer om att begränsa anrop till dina tRPC-procedurer i det här inlägget från Unkey.

Möjligheterna är oändliga, och jag slår vad om att du redan har en massa bra verktygsmiddlewares som du använder i dina tRPC-applikationer idag. Om inte, kanske du hittar några du kan npm installa!

Avslutningsvis

Serveråtgärder är inget silverkul. I situationer som kräver mer dynamisk data kanske du vill behålla din data i React Queries klient-sidcache och göra mutationer med useMutation istället. Det är helt legitimt. Dessa nya primitiv bör också vara lätta att adoptera gradvis, så du kan flytta individuella procedurer från din befintliga tRPC-API till serveråtgärder där det är meningsfullt. Det finns inget behov av att skriva om hela ditt API.

Genom att definiera dina serveråtgärder med tRPC kan du återanvända mycket av samma logik du använder idag och välja var du exponerar mutationen som en serveråtgärd eller som en mer traditionell mutation. Du som utvecklare har makten att välja vilka mönster som fungerar bäst för din applikation. Om du inte använder tRPC idag finns det några paket (next-safe-action och zsa kommer att tänka på) som låter dig definiera typsäkra, indatavaliderade serveråtgärder som också är värda att kolla in.

Om du vill se en app som använder detta i praktiken, kolla in Trellix tRPC, en app jag nyligen gjorde som utnyttjar dessa nya primitiv.

Vad tycker du? Vi vill ha din feedback

Så, vad tycker du? Berätta för oss på Github och hjälp oss att iterera för att få dessa primitiv till ett stabilt tillstånd.

Det finns fortfarande arbete kvar, särskilt när det gäller felhantering. Next.js förespråkar att returnera fel, och vi vill göra detta så typsäkert som möjligt. Kolla in denna pågående PR av Alex för tidigt arbete på detta.

Tills nästa gång, lycka till med kodandet!