First Project
A comprehensive walkthrough of creating your first SaaS project with SaaSFoundryAI.
What You'll Build
In this guide, you'll:
- Create a new SaaSFoundryAI project
- Understand the generated structure
- Run the development environment
- Create a custom API endpoint
- Add a new frontend page
- Make your first commit with your configured coding agent
Time Required: ~30 minutes
Prerequisites
- Node.js 24.19.0 installed (the version pinned by generated
.nvmrcfiles) - Docker Desktop (for database and S3)
- A registered coding-agent tool installed (optional but recommended)
- Basic knowledge of TypeScript, React, and NestJS
Step 1: Create Your Project
Open your terminal and run:
sf newAnswer the prompts as follows (for this tutorial):
? What would you like SaaSFoundryAI to install?
→ Full — technical stack and AI harness
? Which coding agents should share this harness?
→ Select the tools you use (for example Claude Code and Codex)
? What is the name of your project?
→ my-first-saas
? What is the description of your project?
→ My first SaaSFoundryAI project (press Enter)
? Which main branch name do you prefer?
→ main
? How would you like to structure your project?
→ Monorepo (Turborepo)
? Do you have already a remote repository?
→ Not yet, just setup on local
? Do you want to set up a development database with Docker?
→ Yes, with Docker
Database credentials (use defaults):
User: db_dev_user
Password: db_dev_password
Database: db_dev
? For your transactional emails...
→ None, just set up the logic
? Do you want to set up object storage (S3)?
→ Yes, add MinIO with Docker
? Do you want to include Umami analytics?
→ NoWait for generation (this takes ~1-2 minutes):
- Files are being created
- Dependencies are being installed
- Git repository is initialized
✅ Success! Your project is ready.
Step 2: Explore the Generated Structure
Navigate into your project:
cd my-first-saasYour project structure:
my-first-saas/
├── apps/
│ ├── api/ # Backend (NestJS)
│ └── web/ # Frontend (React)
├── CLAUDE.md # Canonical Claude entrypoint
├── AGENTS.md # Shared agent entrypoint
├── GEMINI.md # Gemini entrypoint
├── .claude/skills/ # Existing harness skill source
├── .agents/skills/ # Shared-profile skill surface when declared
├── docker-compose.dev-services.yml
├── turbo.json
└── package.jsonAPI Structure (apps/api/)
api/
├── src/
│ ├── modules/
│ │ ├── auth/ # JWT authentication
│ │ ├── users/ # User management
│ │ ├── organizations/ # Multi-tenancy
│ │ ├── invitation/ # Team invitations
│ │ ├── email/ # Email service
│ │ └── storage/ # S3 file uploads
│ ├── configs/ # Environment variables
│ ├── common/ # Shared utilities
│ └── main.ts # Application entry
├── prisma/
│ └── schema/ # Database models
└── tests/ # E2E testsWeb Structure (apps/web/)
web/
├── src/
│ ├── pages/
│ │ ├── private/ # Protected pages (dashboard)
│ │ └── public/ # Public pages (login, register)
│ ├── components/
│ │ ├── layout/ # Layout components
│ │ ├── nav/ # Navigation
│ │ └── ui/ # ShadCN UI components
│ ├── hooks/
│ │ └── api/ # React Query API hooks
│ ├── router/ # React Router v7 config
│ └── locales/ # i18n translations (EN/FR)
└── tests/ # Playwright E2E testsStep 3: Start Development Environment
Start Docker Services
From the monorepo root:
npm run services:upThis delegates to apps/api/docker-compose.dev-services.yml and starts:
- PostgreSQL (port 5435)
- MinIO S3 (port 9000, console 9001)
Verify services are running:
docker psYou should see saasfoundry-db-dev and saasfoundry-s3-dev (plus saasfoundry-s3-init on first boot).
Initialize Database
npm run db:setup:devThis runs the generated development database setup, including prisma db push --force-reset, and regenerates the Prisma client. It resets development data; do not use it against a production database.
Start Dev Servers
From the monorepo root:
npm run devTurborepo starts both API and Web in parallel:
API: http://localhost:3500
Web: http://localhost:5173Wait for the [api] line reporting the application is running and the [web] line reporting Vite is ready.
Step 4: Test the Generated App
Register an Account
- Open http://localhost:5173 — you'll be redirected to
/signin. - Click "Sign Up".
- Fill the registration form (the scaffold collects only what the SignUp DTO enforces):
- Email:
test@example.com - Password:
Test123!(min 8 chars, at least one lower + one upper + one digit)
- Email:
- Click "Create Account".
A confirmation email is dispatched via the email module if it's configured. In dev mode without a real SMTP provider, inspect the API logs — the scaffold logs the confirmation token so you can validate the account manually.
Once validated, sign in and you'll land on /dashboard.
Explore the App
The scaffold ships two authenticated routes:
/dashboard— a placeholder "work in progress" landing page, ready for you to extend./account— the account management surface (profile, organization settings, members, invitations).
All the people/organization/entity data lives under /account. There is no separate "Profile" or "Organization" top-level route — the scaffold deliberately keeps a single admin surface so you decide how to split it as your product grows.
API Documentation
Open http://localhost:3500/api/docs to see the auto-generated Swagger UI.
Try the Auth endpoints exposed by apps/api/src/modules/auth/controllers/auth.controller.ts:
POST /api/auth/signup— create an accountPOST /api/auth/signin— authenticate (sets HTTP-only cookies)POST /api/auth/signout— invalidate the sessionGET /api/auth/me— fetch the authenticated user's profilePOST /api/auth/request-password-reset— request a reset emailPOST /api/auth/reset-password— reset with a token
Because auth cookies are HTTP-only, signing in from the Swagger UI won't carry the session across requests. Use the web app for the login flow, then re-open Swagger in the same browser session if you need to hit authenticated endpoints interactively.
Step 5: Create Your First API Endpoint
Let's add a user-scoped tasks feature — each user sees only their own tasks. For tenant-isolated features you would scope by accountId instead (see Module System for how Accounts, Entities, and Organizations relate in the scaffold).
Working from the monorepo root:
cd apps/api
mkdir -p src/modules/tasks/{controllers,services,dto/requests}Define the Prisma model
Create apps/api/prisma/schema/tasks.prisma:
model Task {
id String @id @default(cuid())
title String @db.VarChar(140)
completed Boolean @default(false)
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("tasks")
}Add the back-relation on User in apps/api/prisma/schema/users.prisma:
model User {
// ... existing fields
tasks Task[]
}Apply the migration:
npm run db:setup:devThis regenerates the Prisma client at apps/api/src/generated/prisma/ — the scaffold reads Prisma types from there, not from @prisma/client.
Implement the service
Create apps/api/src/modules/tasks/services/tasks.service.ts:
import { Injectable, NotFoundException } from '@nestjs/common'
import { PrismaService } from '@configs/prisma/services/prisma.service'
import type { CreateTaskDto } from '@modules/tasks/dto/requests/create-task.dto'
@Injectable()
export class TasksService {
constructor(private readonly prisma: PrismaService) {}
create(userId: string, dto: CreateTaskDto) {
return this.prisma.task.create({ data: { ...dto, userId } })
}
findAllForUser(userId: string) {
return this.prisma.task.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
})
}
async toggle(userId: string, id: string) {
const task = await this.prisma.task.findFirst({ where: { id, userId } })
if (!task) throw new NotFoundException('Task not found')
return this.prisma.task.update({ where: { id }, data: { completed: !task.completed } })
}
async remove(userId: string, id: string) {
await this.prisma.task.deleteMany({ where: { id, userId } })
}
}Create the DTO
Create apps/api/src/modules/tasks/dto/requests/create-task.dto.ts:
import { ApiProperty } from '@nestjs/swagger'
import { IsNotEmpty, IsString, MaxLength } from 'class-validator'
export class CreateTaskDto {
@ApiProperty({ example: 'Finish the getting started guide' })
@IsString()
@IsNotEmpty()
@MaxLength(140)
title: string
}Implement the controller
Create apps/api/src/modules/tasks/controllers/tasks.controller.ts. The scaffold's JwtAuthGuard attaches the authenticated user on request.user:
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'
import { ApiOperation, ApiTags } from '@nestjs/swagger'
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'
import { CreateTaskDto } from '@modules/tasks/dto/requests/create-task.dto'
import { TasksService } from '@modules/tasks/services/tasks.service'
import type { AuthenticatedRequest } from '@common/types/authenticated-request.type'
@ApiTags('Tasks')
@UseGuards(JwtAuthGuard)
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Post()
@ApiOperation({ summary: 'Create a task for the current user' })
create(@Req() req: AuthenticatedRequest, @Body() dto: CreateTaskDto) {
return this.tasksService.create(req.user.id, dto)
}
@Get()
@ApiOperation({ summary: 'List the current user tasks' })
findAll(@Req() req: AuthenticatedRequest) {
return this.tasksService.findAllForUser(req.user.id)
}
@Patch(':id/toggle')
toggle(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.tasksService.toggle(req.user.id, id)
}
@Delete(':id')
remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.tasksService.remove(req.user.id, id)
}
}Wire the module
Create apps/api/src/modules/tasks/tasks.module.ts:
import { Module } from '@nestjs/common'
import { TasksController } from '@modules/tasks/controllers/tasks.controller'
import { TasksService } from '@modules/tasks/services/tasks.service'
@Module({
controllers: [TasksController],
providers: [TasksService]
})
export class TasksModule {}Register it in apps/api/src/app.module.ts:
import { TasksModule } from '@modules/tasks/tasks.module'
@Module({
imports: [
// ... existing modules
TasksModule
]
})
export class AppModule {}Test your endpoint
Restart the API (npm run dev picks up the schema change; a hard restart can help after Prisma regeneration).
Open http://localhost:3500/api/docs. Because the scaffold uses HTTP-only auth cookies, you need to sign in from the web app first (http://localhost:5173), then keep the same browser tab open for the Swagger call. POST /api/tasks:
{ "title": "My first task" }Then GET /api/tasks to see the list.
✅ Your API endpoint works!
Step 6: Create Your First Frontend Page
Let's create a Tasks page that talks to the endpoint you just built.
Create the API hooks
Create apps/web/src/hooks/api/tasks/index.ts:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import apiClient from '@/lib/api/client'
export type Task = {
id: string
title: string
completed: boolean
createdAt: string
}
export const useTasks = () =>
useQuery({
queryKey: ['tasks'],
queryFn: () => apiClient.get<Task[]>('/tasks')
})
export const useCreateTask = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: { title: string }) => apiClient.post<Task>('/tasks', payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] })
})
}
export const useToggleTask = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => apiClient.patch<Task>(`/tasks/${id}/toggle`, {}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] })
})
}
export const useDeleteTask = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => apiClient.delete<void>(`/tasks/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] })
})
}The scaffold's
apiClientuses the native Fetch API and returns the JSON body directly — there is no.datawrapper. Auth cookies are sent automatically thanks tocredentials: 'include'.
Create the page
Create apps/web/src/pages/private/tasks.tsx (the scaffold follows kebab-case filenames + PascalCase exports):
import { useState, type FormEvent } from 'react'
import { Button } from '@/components/ui/shadcn/button'
import { Checkbox } from '@/components/ui/shadcn/checkbox'
import { Input } from '@/components/ui/shadcn/input'
import { useCreateTask, useDeleteTask, useTasks, useToggleTask } from '@/hooks/api/tasks'
export const Tasks = () => {
const [title, setTitle] = useState('')
const { data: tasks = [], isLoading } = useTasks()
const createTask = useCreateTask()
const toggleTask = useToggleTask()
const deleteTask = useDeleteTask()
const handleCreate = (event: FormEvent) => {
event.preventDefault()
if (!title.trim()) return
createTask.mutate({ title })
setTitle('')
}
if (isLoading) return <p className="p-8 text-sm text-muted-foreground">Loading...</p>
return (
<div className="container mx-auto p-8">
<h1 className="mb-6 text-3xl font-bold">Tasks</h1>
<form onSubmit={handleCreate} className="mb-8 flex gap-2">
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Add a new task..." className="flex-1" />
<Button type="submit" disabled={createTask.isPending}>
Add Task
</Button>
</form>
<div className="space-y-2">
{tasks.map((task) => (
<div key={task.id} className="flex items-center gap-3 rounded-lg border p-4">
<Checkbox checked={task.completed} onCheckedChange={() => toggleTask.mutate(task.id)} />
<span className={task.completed ? 'text-muted-foreground line-through' : ''}>{task.title}</span>
<Button variant="ghost" size="sm" onClick={() => deleteTask.mutate(task.id)} className="ml-auto">
Delete
</Button>
</div>
))}
</div>
</div>
)
}Register the route
The scaffold declares routes as RouteObject[] with lazy-loaded pages — not JSX <Route> elements.
Add the lazy import to apps/web/src/router/lazy-pages.tsx:
// --- tasks ---
export const Tasks = lazy(() => import('@/pages/private/tasks').then((module) => ({ default: module.Tasks })))Add the route entry to apps/web/src/router/private-routes.tsx inside the LayoutLogged children list:
import { AccountManagement, Dashboard, LayoutLogged, Tasks } from '@/router/lazy-pages'
// ...
{
path: 'tasks',
element: LazyRouteElement(Tasks)
}Add a sidebar entry
The main sidebar lives at apps/web/src/components/layout/layout-sidebar.tsx. Its data.navigation array drives the nav groups. Add a new item to one of the groups — for example:
import { CheckSquare } from 'lucide-react'
// inside data.navigation[0].items
{
title: 'Tasks',
url: '/tasks',
icon: CheckSquare,
isActive: true
}The existing entries use i18n keys (
main-navigation.tk_feature-1_) because the scaffold renders them viauseTranslation('nav'). For a tutorial shortcut, a plain string works; for a production build, add the translation keys inapps/web/src/locales/{en,fr}/nav.yml.
Test Your Page
- Open http://localhost:5173/tasks (sign in first if needed).
- Add a task via the form.
- Check/uncheck to toggle completion.
- Delete a task.
✅ Your frontend page works end-to-end against the new API.
Step 7: Make Your First Commit
The monorepo enforces conventional commits with a mandatory ticket scope via Husky + commitlint:
<type>(#<ticket>): <description>With a configured coding agent
Open the project in one of the profiles reported by sf agents list, then ask:
Commit these changes using the project's commit skillThe commit procedure reads the required pattern from .saasfoundry.json, groups related changes, and writes a conventional commit. Follow the skill location exposed by the selected profile; do not invent a provider-specific trailer when the project does not require one.
Manually
git add apps/api/src/modules/tasks \
apps/api/prisma/schema/tasks.prisma \
apps/api/prisma/schema/users.prisma \
apps/web/src/hooks/api/tasks \
apps/web/src/pages/private/tasks.tsx \
apps/web/src/router/lazy-pages.tsx \
apps/web/src/router/private-routes.tsx \
apps/web/src/components/layout/layout-sidebar.tsx
git commit -m "feat(#1): add tasks module with API, hooks and UI"Replace #1 with the ticket number from your issue tracker. Husky's commit-msg hook will reject commits that don't match the scoped pattern.
What's Next?
Congratulations! 🎉 You've successfully:
- ✅ Created a SaaSFoundryAI project
- ✅ Added a custom API endpoint
- ✅ Built a frontend page
- ✅ Made your first commit
Continue Learning
- Add Authentication to Tasks: Implement permissions
- Add Tests: Write E2E tests for tasks
- Deploy: Learn how to deploy to production
- Add More Modules: Try
sf updateto add email or storage
Resources
- Project Structure - Deep dive into the codebase
- Module System - Understanding modules
- Skills System - Using project skills
sf agents- Add or verify another coding-agent profile- CLI Commands - All available commands
Troubleshooting
API Won't Start
- Check services are up:
docker psshould listsaasfoundry-db-dev(andsaasfoundry-s3-devif you chose MinIO). - Check database logs:
docker logs saasfoundry-db-dev - Restart from scratch:
npm run services:reset(down + up), thennpm run db:setup:dev.
Frontend Won't Start
- Check API is running:
curl http://localhost:3500/api/healthshould return a 200. - Clear Vite cache:
rm -rf apps/web/node_modules/.vite.
Database Migration Fails
Reset database (tmpfs is wiped on stop, so a full down/up recreates an empty DB):
npm run services:reset
npm run db:setup:devPort Already in Use
The scaffold reads ports from environment files — edit those, not the source code:
- API:
apps/api/.env→PORT(default3500) - Web:
apps/web/.env→FRONTEND_PORT(default5173)
If you change the API port, also update apps/web/.env → VITE_BASE_API_URL so the frontend still reaches the backend.