Add a resource
Most of what you build is something people create, see and delete. This page builds projects
from the database to the screen; copy it for your own. The finished result is the branch
example/projects of the kit: git fetch upstream, then git diff upstream/main upstream/example/projects
shows every file a new resource touches. Why it is shaped this way is on
Architecture.
1. The contract
Section titled “1. The contract”Create packages/contracts/src/projects.ts:
import { z } from 'zod';
export const createProjectInputSchema = z.object({ name: z.string().trim().min(1, 'Give the project a name').max(80),});export type CreateProjectInput = z.infer<typeof createProjectInputSchema>;
export const projectSchema = z.object({ id: z.uuid(), name: z.string(), createdAt: z.iso.datetime(),});export type Project = z.infer<typeof projectSchema>;
export const projectsSchema = z.array(projectSchema);Export it from packages/contracts/src/index.ts:
export * from './projects.js';Deleting a project will be recorded in the audit log, so name that event: add
'project.deleted' to AUDIT_ACTIONS in packages/contracts/src/audit.ts, and its sentence to
sentences in packages/contracts/src/describe-event.ts:
'project.deleted': ({ target }) => `deleted the project ${target}`,2. The table
Section titled “2. The table”Declare it with the ORM you chose (DATABASE_ORM, see Database).
The organizationId ties each row to a workspace and deletes it with the workspace.
With Drizzle, create apps/api/src/db/schema/projects.ts and export it from
apps/api/src/db/schema.ts:
import { index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';import { organizations } from './auth.js';
export const projects = pgTable( 'projects', { id: uuid().defaultRandom().primaryKey(), organizationId: uuid() .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), name: text().notNull(), createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), }, (table) => [index('projects_organization_id_idx').on(table.organizationId)],);With Prisma, add the model to apps/api/prisma/schema.prisma, and projects Project[] to
Organization:
model Project { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid organizationId String @map("organization_id") @db.Uuid name String createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId]) @@map("projects")}With TypeORM, create apps/api/src/db/typeorm/entities/projects.ts, and add Project to
ENTITIES in apps/api/src/db/typeorm/entities/index.ts:
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';import { Organization } from './auth.js';import { createdAt, id } from './columns.js';
@Entity('projects')@Index('projects_organization_id_idx', ['organizationId'])export class Project { @id('projects') id!: string; @Column({ name: 'organization_id', type: 'uuid' }) organizationId!: string; @Column('text') name!: string; @createdAt() createdAt!: Date;
@ManyToOne(() => Organization, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'organization_id' }) organization?: Organization;}Then write and apply the migration. With Prisma this also regenerates its client; with TypeORM it compares the entities with your database, so migrate that first:
bun run --cwd apps/api db:generate --name projectsbun run db:migrateCommit what it wrote, in apps/api/drizzle, apps/api/prisma or
apps/api/src/db/typeorm/migrations. Never edit a committed
migration: change the table and generate another.
3. The service
Section titled “3. The service”Create apps/api/src/projects/projects.service.ts. Every query filters by the workspace,
the delete included: that is what keeps one customer out of another’s data. Your own services
use the ORM directly, through DrizzleDatabase or PrismaDatabase, whose client is the open
transaction when there is one, or TypeOrmDatabase, whose manager is.
With Drizzle:
import { Injectable, NotFoundException } from '@nestjs/common';import type { CreateProjectInput, Project } from '@saas-stack/contracts';import { and, desc, eq } from 'drizzle-orm';import { AuditService } from '../audit/audit.service.js';import type { CurrentUser } from '../auth/auth.decorators.js';import { DrizzleDatabase } from '../db/drizzle/drizzle.database.js';import { projects } from '../db/schema.js';
type Row = typeof projects.$inferSelect;
const toProject = (row: Row): Project => ({ id: row.id, name: row.name, createdAt: row.createdAt.toISOString(),});
@Injectable()export class ProjectsService { constructor( private readonly db: DrizzleDatabase, private readonly audit: AuditService, ) {}
async list(workspaceId: string): Promise<Project[]> { const rows = await this.db.client .select() .from(projects) .where(eq(projects.organizationId, workspaceId)) .orderBy(desc(projects.createdAt)); return rows.map(toProject); }
async create(workspaceId: string, input: CreateProjectInput): Promise<Project> { const [row] = await this.db.client .insert(projects) .values({ organizationId: workspaceId, name: input.name }) .returning(); return toProject(row!); }
async remove(workspaceId: string, id: string, actor: CurrentUser): Promise<void> { // The audit event takes part in the transaction: it exists only if the project is gone. await this.db.transaction(async () => { const [removed] = await this.db.client .delete(projects) .where(and(eq(projects.id, id), eq(projects.organizationId, workspaceId))) .returning({ name: projects.name }); if (!removed) throw new NotFoundException('There is no such project'); await this.audit.record({ workspaceId, actor, action: 'project.deleted', target: removed.name, }); }); }}With Prisma, the same service injects PrismaDatabase (from ../db/prisma/prisma.database.js),
and its queries are Prisma’s:
async list(workspaceId: string): Promise<Project[]> { const rows = await this.db.client.project.findMany({ where: { organizationId: workspaceId }, orderBy: { createdAt: 'desc' }, }); return rows.map(toProject);}
async remove(workspaceId: string, id: string, actor: CurrentUser): Promise<void> { await this.db.transaction(async () => { const removed = await this.db.client.project.findFirst({ where: { id, organizationId: workspaceId }, }); if (!removed) throw new NotFoundException('There is no such project'); await this.db.client.project.delete({ where: { id: removed.id } }); await this.audit.record({ workspaceId, actor, action: 'project.deleted', target: removed.name }); });}With TypeORM, it injects TypeOrmDatabase (from ../db/typeorm/typeorm.database.js) and uses
its manager:
async list(workspaceId: string): Promise<Project[]> { const rows = await this.db.manager.find(ProjectEntity, { where: { organizationId: workspaceId }, order: { createdAt: 'DESC' }, }); return rows.map(toProject);}
async remove(workspaceId: string, id: string, actor: CurrentUser): Promise<void> { await this.db.transaction(async () => { const removed = await this.db.manager.findOneBy(ProjectEntity, { id, organizationId: workspaceId }); if (!removed) throw new NotFoundException('There is no such project'); await this.db.manager.delete(ProjectEntity, { id: removed.id }); await this.audit.record({ workspaceId, actor, action: 'project.deleted', target: removed.name }); });}ProjectEntity is the entity, imported under another name than the contract’s Project:
import { Project as ProjectEntity } from '../db/typeorm/entities/index.js'.
4. The controller
Section titled “4. The controller”Create apps/api/src/projects/projects.controller.ts. It maps HTTP to the service and nothing
else:
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Post,} from '@nestjs/common';import { createProjectInputSchema, type CreateProjectInput, type Project,} from '@saas-stack/contracts';import { CurrentUser } from '../auth/auth.decorators.js';import { CurrentWorkspace, RequireRole, WorkspaceScoped, type WorkspaceContext,} from '../workspaces/workspace.decorators.js';import { ProjectsService } from './projects.service.js';
@WorkspaceScoped()@Controller('workspaces/:workspaceId/projects')export class ProjectsController { constructor(private readonly projects: ProjectsService) {}
@Get() list(@CurrentWorkspace() workspace: WorkspaceContext): Promise<Project[]> { return this.projects.list(workspace.id); }
@Post() create( @CurrentWorkspace() workspace: WorkspaceContext, @Body({ schema: createProjectInputSchema }) input: CreateProjectInput, ): Promise<Project> { return this.projects.create(workspace.id, input); }
@RequireRole('admin') @Delete(':projectId') @HttpCode(204) remove( @CurrentWorkspace() workspace: WorkspaceContext, @CurrentUser() user: CurrentUser, @Param('projectId', ParseUUIDPipe) projectId: string, ): Promise<void> { return this.projects.remove(workspace.id, projectId, user); }}Non-members get a 404, invalid input a 400, and members trying to delete a 403. Who deleted what shows in the audit log.
5. The module
Section titled “5. The module”Create apps/api/src/projects/projects.module.ts:
import { Module } from '@nestjs/common';import { ProjectsController } from './projects.controller.js';import { ProjectsService } from './projects.service.js';
@Module({ controllers: [ProjectsController], providers: [ProjectsService],})export class ProjectsModule {}Add ProjectsModule to the imports of AppModule in apps/api/src/app.module.ts. Your own
services need no repository interface: that is for the kit’s modules, which work with any of the three.
6. The test
Section titled “6. The test”Create apps/api/test/projects.e2e.test.ts. The test that matters most is that one workspace
cannot reach another’s data:
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';import { meSchema, projectSchema, projectsSchema } from '@saas-stack/contracts';import { eq } from 'drizzle-orm';import { auditEvents } from '../src/db/schema.js';import { createTestApp, type Client, type TestApp } from './helpers.js';
async function workspaceOf(client: Client): Promise<string> { const me = meSchema.parse(await (await client.get('/api/me')).json()); return me.workspaces[0]!.id;}
describe('projects', () => { let t: TestApp; let ada: Client; let grace: Client; let adas: string;
beforeAll(async () => { t = await createTestApp({ teams: false }); [ada, grace] = await Promise.all([ t.signUp({ name: 'Ada', email: 'ada@example.com' }), t.signUp({ name: 'Grace', email: 'grace@example.com' }), ]); adas = await workspaceOf(ada); });
afterAll(() => t.close());
it('creates a project and lists it', async () => { const created = projectSchema.parse( await ada.postOk(`/api/workspaces/${adas}/projects`, { name: 'Engine' }), ); const listed = projectsSchema.parse( await (await ada.get(`/api/workspaces/${adas}/projects`)).json(), ); expect(listed).toEqual([created]); });
it('refuses a project without a name', async () => { const res = await ada.post(`/api/workspaces/${adas}/projects`, { name: ' ' }); expect(res.status).toBe(400); });
it('hides the projects of one workspace from everyone else', async () => { expect((await grace.get(`/api/workspaces/${adas}/projects`)).status).toBe(404);
const graces = await workspaceOf(grace); const hers = await (await grace.get(`/api/workspaces/${graces}/projects`)).json(); expect(hers).toEqual([]); });
it('cannot delete a project through another workspace', async () => { const [project] = projectsSchema.parse( await (await ada.get(`/api/workspaces/${adas}/projects`)).json(), ); const graces = await workspaceOf(grace);
const res = await grace.request('DELETE', `/api/workspaces/${graces}/projects/${project!.id}`);
expect(res.status).toBe(404); expect(await (await ada.get(`/api/workspaces/${adas}/projects`)).json()).toHaveLength(1); });});
// Roles only differ with teams on: with personal accounts everyone owns their workspace.describe('projects in a team', () => { let t: TestApp; let owner: Client; let member: Client; let workspaceId: string;
beforeAll(async () => { t = await createTestApp({ teams: true }); owner = await t.signUp({ name: 'Olive', email: 'olive@example.com' }); workspaceId = ( await owner.postOk<{ id: string }>('/api/auth/organization/create', { name: 'Acme', slug: 'acme', }) ).id; member = await t.addMember(owner, workspaceId, { name: 'Mia', email: 'mia@example.com', role: 'member', }); });
afterAll(() => t.close());
it('lets members add projects, and only admins delete them, on the record', async () => { const path = `/api/workspaces/${workspaceId}/projects`; const project = projectSchema.parse(await member.postOk(path, { name: 'Engine' }));
expect((await member.request('DELETE', `${path}/${project.id}`)).status).toBe(403); expect((await owner.request('DELETE', `${path}/${project.id}`)).status).toBe(204);
const [event] = await t.db .select() .from(auditEvents) .where(eq(auditEvents.action, 'project.deleted')); expect(event).toMatchObject({ organizationId: workspaceId, target: 'Engine', actorName: 'Olive', }); });});t.addMember() signs someone up and brings them into a workspace with a role. t.db is Drizzle on
the test database, whichever ORM the app runs, for setting up and reading what a test needs. Add
projects to the truncate in createTestApp in apps/api/test/helpers.ts, so every test file
starts with an empty table. Then run this one file:
bun run --cwd apps/api test test/projects.e2e.test.ts7. The queries in the app
Section titled “7. The queries in the app”Create apps/web/src/features/projects/queries.ts. api() checks every answer against the
contract:
import { projectSchema, projectsSchema, type CreateProjectInput } from '@saas-stack/contracts';import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';import { z } from 'zod';import { api } from '@/lib/api';
export const projectsQuery = (workspaceId: string) => queryOptions({ queryKey: ['workspaces', workspaceId, 'projects'], queryFn: () => api(`/workspaces/${workspaceId}/projects`, projectsSchema), });
export function useCreateProject(workspaceId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (input: CreateProjectInput) => api(`/workspaces/${workspaceId}/projects`, projectSchema, { method: 'POST', body: input }), onSuccess: () => queryClient.invalidateQueries(projectsQuery(workspaceId)), });}
export function useRemoveProject(workspaceId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: string) => api(`/workspaces/${workspaceId}/projects/${id}`, z.null(), { method: 'DELETE' }), onSuccess: () => queryClient.invalidateQueries(projectsQuery(workspaceId)), });}8. The screen
Section titled “8. The screen”Create apps/web/src/features/projects/projects.tsx. The form validates with the same schema as
the API:
import { zodResolver } from '@hookform/resolvers/zod';import { createProjectInputSchema, type CreateProjectInput } from '@saas-stack/contracts';import { useQuery } from '@tanstack/react-query';import { useForm } from 'react-hook-form';import { ConfirmDialog } from '@/components/confirm-dialog';import { FormError } from '@/components/form/form-error';import { TextField } from '@/components/form/text-field';import { Button } from '@/components/ui/button';import { useWorkspace } from '@/features/workspaces/use-workspace';import { projectsQuery, useCreateProject, useRemoveProject } from './queries';
export function Projects() { const workspace = useWorkspace(); const projects = useQuery(projectsQuery(workspace.id)); const create = useCreateProject(workspace.id); const remove = useRemoveProject(workspace.id); const form = useForm<CreateProjectInput>({ resolver: zodResolver(createProjectInputSchema), defaultValues: { name: '' }, });
return ( <div className="flex flex-col gap-6"> <form className="flex items-end gap-3" onSubmit={form.handleSubmit((input) => create.mutate(input, { onSuccess: () => form.reset() }), )} > <TextField label="Name" error={form.formState.errors.name?.message} {...form.register('name')} /> <Button type="submit" disabled={create.isPending}> Add project </Button> </form> <FormError error={create.error} />
<ul className="divide-y rounded-lg border"> {projects.data?.map((project) => ( <li key={project.id} className="flex items-center justify-between p-4"> <span>{project.name}</span> {workspace.can('admin') && ( <ConfirmDialog trigger={<Button variant="ghost">Delete</Button>} title={`Delete ${project.name}?`} description="This cannot be undone." action="Delete project" onConfirm={() => remove.mutate(project.id)} pending={remove.isPending} error={remove.error} /> )} </li> ))} </ul> </div> );}workspace.can('admin') only hides the button. The API is what refuses. Deleting asks first, as
anything that cannot be undone does.
9. The page and its link
Section titled “9. The page and its link”Create apps/web/src/routes/_authenticated/w.$slug/projects.tsx. The shell shows the title
from staticData, so the page has no <h1>:
import { createFileRoute } from '@tanstack/react-router';import { Projects } from '@/features/projects/projects';
export const Route = createFileRoute('/_authenticated/w/$slug/projects')({ staticData: { title: 'Projects' }, component: Projects,});Add it to the sidebar, in navigation() in apps/web/src/routes/_authenticated/w.$slug.tsx:
{ label: 'Projects', icon: FolderKanban, link: { to: '/w/$slug/projects', params } },Check it
Section titled “Check it”The route tree in apps/web/src/routeTree.gen.ts is rewritten while bun dev runs. If it isn’t
running, rewrite it first, or the new route won’t type-check:
bun run --cwd apps/web buildbun run checkNext: charge for it, record other events, or do slow work in the background.