Mutations
Learn how to write mutations to create, update or delete data.
The dashboard uses Server Actions to handle mutations, making development faster and more efficient.
Why choose Server Actions? A mature SaaS typically manages between 120–400 internal endpoints, with most executing relatively simple code. Only a small subset of endpoints involves complex logic. Despite this, the challenge lies in the necessity of writing and maintaining each endpoint, along with its urls, hooks, query caches, error handling and serialization. This process is not only time-consuming but also prone to errors. Server actions present an appealing alternative, reducing the amount of code and ongoing maintenance. The trade-off, however, is that we must conform to specific patterns, such as not being able to return binary data.
Server actions
Server actions are a React/Next.js feature that let you write server-side logic callable directly from the client.
Server actions in the starter kit are implemented with the Next Safe Action library.
To create a server action add the 'use server'
directive at the top. This instructs Next.js that the file contains server-side logic, which can be called from the client side. Note that it creates an implicit POST
endpoint.
Add a file under apps/dashboard/actions/my-action.ts
with the following code:
'use server';
import { actionClient } from '~/actions/safe-action';
export const myAction = actionClient
.metadata({ actionName: 'myAction' })
.action(async () => {
return 'Hello World';
});
The metadata call is not necessary but helps us for debugging, logging and observability.
Now we can use the server actions anywhere in our client components:
'use client';
import { Button } from '@workspace/ui/components/button';
import { myAction } from '~/actions/my-action';
function ClientComponent() {
const onClick = () => {
const result = await myAction();
console.log(result);
}
return <Button onClick={onClick}>Test</Button>;
}
Input validation
We use Zod to validate any input. This is necessary during runtime, since we can't just rely on the Typescript types which are a compile-time thing.
Add a new schema at apps/dashboard/schemas/my-action-schema.ts
;
import { z } from 'zod';
export const myActionSchema = z.object({
name: z.string().min(1)
});
export type MyActionSchema = z.infer<typeof myActionSchema>;
We put the schema in a different file, because we can reuse it for client-side form validation.
Let's use the validation schema:
'use server';
import { actionClient } from '~/actions/safe-action';
import { myActionSchema } from '~/schemas/my-action-schema';
export const myAction = actionClient
.metadata({ actionName: 'myAction' })
.schema(myActionSchema)
.action(async ({ parsedInput }) => {
return `Hello ${parsedInput.name}`;
});
Let's see how it looks like on the client:
'use client';
import { Button } from '@workspace/ui/components/button';
import { myAction } from '~/actions/my-action';
function ClientComponent() {
const onClick = () => {
const result = await myAction({ name: 'John Doe' });
if (result?.serverError) {
console.log("Something went wrong.");
}
if (result?.validationErrors) {
console.log("Validation failed.");
}
}
return <Button onClick={onClick}>Test</Button>;
}
We can access server and validation errors. The validation errors are flattened zod errors. Use a ?
to check if a result exists in case the server action aborts early.
Authentication Context
Change the client from actionClient
to authActionClient
:
'use server';
import { prisma } from '@workspace/database/client';
import { authActionClient } from '~/actions/safe-action';
import { myActionSchema } from '~/schemas/my-action-schema';
export const myAction = authActionClient
.metadata({ actionName: 'myAction' })
.schema(myActionSchema)
.action(async ({ parsedInput, ctx }) => {
const session = ctx.session;
const user = ctx.session.user;
return `Hello ${parsedInput.name}`;
});
Now the action will redirect unauthenticated users and you have access to the session object.
Authentication organization context
Similar concept, change the client to authOrganizationActionClient
:
'use server';
import { authOrganizationActionClient } from '~/actions/safe-action';
import { myActionSchema } from '~/schemas/my-action-schema';
export const myAction = authOrganizationActionClient
.metadata({ actionName: 'myAction' })
.schema(myActionSchema)
.action(async ({ parsedInput, ctx }) => {
const session = ctx.session;
const user = ctx.session.user;
const organization = ctx.organization;
const memberships = ctx.organization.memberships;
return `Hello ${parsedInput.name}`;
});
The actions will use the slug in the path to derive the active organization. Furthermore the membership is checked. Non-members are redirected to the organizations overview and you have access to the active organization.
The client authOrganizationActionClient
should only be called inside an organization route since we need the slug to derive the organization.
Database client
Let's import prisma
and do some database operation:
'use server';
import { actionClient } from '~/actions/safe-action';
import { myActionSchema } from '~/schemas/my-action-schema';
export const myAction = authOrganizationActionClient
.metadata({ actionName: 'myAction' })
.schema(myActionSchema)
.action(async ({ parsedInput, ctx }) => {
const newItem = await prisma.item.create({
data: {
organizationId: ctx.organization.id,
name: parsedInput.name
}
});
return `Hello ${parsedInput.name} with id ${newItem.id}`;
});
Notice how we pass the organizationId
from the context? This is important for security, since it has to be user-unobtainable data. Never use an organizationId
or userId
through parsedInput.
The same applies to updating:
'use server';
import { actionClient } from '~/actions/safe-action';
import { myActionSchema } from '~/schemas/my-action-schema';
export const myAction = authOrganizationActionClient
.metadata({ actionName: 'myAction' })
.schema(myActionSchema)
.action(async ({ parsedInput, ctx }) => {
await prisma.item.update({
where: {
id: parsedInput.id,
organizationId: ctx.organization.id,
},
data: {
name: parsedInput.name
}
});
});
and deleting an item:
'use server';
import { actionClient } from '~/actions/safe-action';
import { myActionSchema } from '~/schemas/my-action-schema';
export const myAction = authOrganizationActionClient
.metadata({ actionName: 'myAction' })
.schema(myActionSchema)
.action(async ({ parsedInput, ctx }) => {
await prisma.item.delete({
where: {
id: parsedInput.id,
organizationId: ctx.organization.id,
}
});
});
Route handlers
In rare cases, such as when you need to return binary data, you can fall back to using route handlers. For example, in the starter kit, we use POST route handlers for Excel and CSV exports.