This guide walks you through adding new features to SaaS Forge, following best practices and the established architecture patterns.
Feature Development Workflow
1. Plan the Feature
Before writing code, clarify:
- Requirements: What does the feature do?
- Data Model: What data needs to be stored?
- API Endpoints: What operations are needed?
- UI Components: What interfaces are required?
- Permissions: Who can access this feature?
2. Update Database Schema
If your feature requires new data:
Edit Prisma Schema (packages/database/prisma/schema.prisma):
model Task {
id String @id @default(cuid())
title String
description String?
completed Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("tasks")
@@schema("user_schema")
}
// Add relation to User model
model User {
// ... existing fields
tasks Task[]
}
Run Migrations:
# Generate Prisma client
pnpm --filter @workspace/database generate
# Create and apply migration
pnpm --filter @workspace/database migrate
3. Create TRPC Router
Create Router File (apps/web/trpc/routers/taskRouter.ts):
import { createTRPCRouter, protectedProcedure } from "@/trpc/init";
import { z } from "zod";
import db from "@workspace/database/client";
import { TRPCError } from "@trpc/server";
const createTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
});
const updateTaskSchema = z.object({
id: z.string(),
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
completed: z.boolean().optional(),
});
export const taskRouter = createTRPCRouter({
// List user's tasks
list: protectedProcedure.query(async ({ ctx }) => {
return await db.task.findMany({
where: { userId: ctx.session.user.id },
orderBy: { createdAt: "desc" },
});
}),
// Create new task
create: protectedProcedure
.input(createTaskSchema)
.mutation(async ({ input, ctx }) => {
return await db.task.create({
data: {
...input,
userId: ctx.session.user.id,
},
});
}),
// Update task
update: protectedProcedure
.input(updateTaskSchema)
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
// Verify ownership
const task = await db.task.findUnique({
where: { id },
});
if (!task || task.userId !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't own this task",
});
}
return await db.task.update({
where: { id },
data,
});
}),
// Delete task
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
// Verify ownership
const task = await db.task.findUnique({
where: { id: input.id },
});
if (!task || task.userId !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't own this task",
});
}
await db.task.delete({
where: { id: input.id },
});
return { success: true };
}),
});
Add to App Router (apps/web/trpc/routers/_app.ts):
import { taskRouter } from "./taskRouter";
export const appRouter = createTRPCRouter({
// ... existing routers
task: taskRouter,
});
4. Create UI Components
Create Shared Components (if reusable):
packages/ui/src/components/TaskCard.tsx:
import { Card, CardContent, CardHeader, CardTitle } from "./shadcn/card";
import { Checkbox } from "./shadcn/checkbox";
interface TaskCardProps {
title: string;
description?: string;
completed: boolean;
onToggle: () => void;
onDelete: () => void;
}
export function TaskCard({
title,
description,
completed,
onToggle,
onDelete,
}: TaskCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Checkbox checked={completed} onCheckedChange={onToggle} />
<span className={completed ? "line-through" : ""}>{title}</span>
</CardTitle>
</CardHeader>
{description && <CardContent>{description}</CardContent>}
</Card>
);
}
5. Create Page/Route
Create Protected Page (apps/web/app/(home)/tasks/page.tsx):
"use client";
import { api } from "@/trpc/client";
import { TaskCard } from "@workspace/ui/components/TaskCard";
import { Button } from "@workspace/ui/components/shadcn/button";
import { useState } from "react";
export default function TasksPage() {
const [isCreating, setIsCreating] = useState(false);
const utils = api.useUtils();
// Fetch tasks
const { data: tasks, isLoading } = api.task.list.useQuery();
// Create task mutation
const createTask = api.task.create.useMutation({
onSuccess: () => {
utils.task.list.invalidate();
setIsCreating(false);
},
});
// Update task mutation
const updateTask = api.task.update.useMutation({
onSuccess: () => {
utils.task.list.invalidate();
},
});
// Delete task mutation
const deleteTask = api.task.delete.useMutation({
onSuccess: () => {
utils.task.list.invalidate();
},
});
if (isLoading) return <div>Loading tasks...</div>;
return (
<div className="container mx-auto py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">My Tasks</h1>
<Button onClick={() => setIsCreating(true)}>New Task</Button>
</div>
<div className="space-y-4">
{tasks?.map((task) => (
<TaskCard
key={task.id}
title={task.title}
description={task.description ?? undefined}
completed={task.completed}
onToggle={() =>
updateTask.mutate({
id: task.id,
completed: !task.completed,
})
}
onDelete={() => deleteTask.mutate({ id: task.id })}
/>
))}
</div>
{/* Add CreateTaskModal component here */}
</div>
);
}
6. Add Navigation
Add link to your new feature in the navigation:
apps/web/components/Navigation.tsx:
<nav>
<Link href="/">Home</Link>
<Link href="/tasks">Tasks</Link> {/* Add this */}
<Link href="/billing">Billing</Link>
</nav>
7. Test the Feature
-
Manual Testing:
pnpm dev # Navigate to /tasks # Test create, update, delete operations -
Type Checking:
pnpm --filter web typecheck -
Linting:
pnpm --filter web lint
Feature Patterns
Adding Public Features
For features accessible without authentication:
- Add route to
publicRoutesinmiddleware.ts - Use
baseProcedureinstead ofprotectedProcedure - Don't access
ctx.session(it might be null)
Adding Admin-Only Features
const adminProcedure = protectedProcedure.use(async (opts) => {
if (!opts.ctx.session.user.isAdmin) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return opts.next();
});
export const adminRouter = createTRPCRouter({
listUsers: adminProcedure.query(async () => {
return await db.user.findMany();
}),
});
Adding Paid Features
Check user credits or subscription:
const paidFeatureProcedure = protectedProcedure.use(async (opts) => {
const user = await db.user.findUnique({
where: { id: opts.ctx.session.user.id },
});
if (!user || user.creditsUsed >= user.creditsTotal) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Insufficient credits",
});
}
return opts.next();
});
Adding Real-time Features
Use Server-Sent Events or WebSockets:
// apps/web/app/api/tasks/subscribe/route.ts
export async function GET(req: Request) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
const stream = new ReadableStream({
start(controller) {
// Send updates to client
const interval = setInterval(async () => {
const tasks = await db.task.findMany({
where: { userId: session.user.id },
});
controller.enqueue(`data: ${JSON.stringify(tasks)}\\n\\n`);
}, 5000);
req.signal.addEventListener("abort", () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
Best Practices
- Start with data model: Get the schema right first
- Use TRPC for APIs: Avoid creating custom API routes unless necessary
- Validate all inputs: Use Zod schemas for type-safe validation
- Check permissions: Always verify user ownership/access rights
- Invalidate queries: Update cache after mutations
- Handle errors gracefully: Show user-friendly error messages
- Keep procedures focused: One procedure = one operation
- Use TypeScript: Leverage full type safety
- Add loading states: Improve perceived performance
- Test on multiple devices: Ensure responsive design
Common Pitfalls
- Forgetting to add router to
_app.ts: Your procedures won't be accessible - Not checking ownership: Users might access others' data
- Missing cache invalidation: UI shows stale data after mutations
- Skipping input validation: Opens security vulnerabilities
- Hardcoding user IDs: Always use
ctx.session.user.id - Not handling errors: App crashes or shows unhelpful messages
