Skip to content

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.

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

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:

Terminal window
bun run --cwd apps/api db:generate --name projects
bun run db:migrate

Commit 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.

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'.

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.

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.

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:

Terminal window
bun run --cwd apps/api test test/projects.e2e.test.ts

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

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.

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

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:

Terminal window
bun run --cwd apps/web build
bun run check

Next: charge for it, record other events, or do slow work in the background.