Mutations
Learn how to write mutations to create, update or delete data.
The dashboard uses Server Actions as the primary pattern to handle mutations.
Server actions
Server actions are a React/Next.js feature that let you write server-side logic callable directly from the client.
Good to know: 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'; });
Good to know: 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 = async () => { 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>;
Good to know: 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 client';import { Button } from '@workspace/ui/components/button';import { myAction } from '~/actions/my-action';function ClientComponent() { const onClick = async () => { const result = await myAction({ name: 'John Doe' }); console.log(result); }; return <Button onClick={onClick}>Test</Button>;}
Let's see how it looks like on the client:
'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}`; });
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 { 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.
Good to know: 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 Drizzle
and do some database operation:
'use server';import { db } from '@workspace/database/client';import { itemTable } from '@workspace/database/schema';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 db .insert(itemTable) .values({ organizationId: ctx.organization.id, name: parsedInput.name }) .returning(); 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 { db } from '@workspace/database/client';import { itemTable } from '@workspace/database/schema';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 db .update(itemTable) .set({ name: parsedInput.name }) .where({ id: parsedInput.id, organizationId: ctx.organization.id }); });
and deleting an item:
'use server';import { db } from '@workspace/database/client';import { itemTable } from '@workspace/database/schema';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 db.delete(itemTable).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.