Published on 2025-03-08
First Steps
Published on
First Steps with SaaS Boilerplate
Now that you have your development environment set up, it's time to start building your SaaS application. This guide will walk you through the essential first steps to get familiar with the SaaS Boilerplate and create your first feature.
Understanding the Architecture
Before diving into code, it's important to understand the architecture of the SaaS Boilerplate. The project follows a feature-based architecture with clean separation of concerns, powered by Igniter.js for type-safe API development.
Key Directories
- src/saas-boilerplate/: Contains core SaaS features like authentication, billing, and organizations
- src/app/: Next.js app router pages organized by section (dashboard, marketing, auth)
- src/content/: Markdown and MDX content for documentation, blog posts, etc.
- src/core/: Core application components, providers, and utilities
- src/features/: Your application-specific features will go here
Exploring the Boilerplate
Start by exploring the existing features and pages to understand how they're implemented. Here are some key areas to check out:
- Authentication Flow: Navigate to /src/saas-boilerplate/features/auth to see how authentication is implemented
- Dashboard Pages: Check the /src/app/(dashboard) directory to see the dashboard UI
- Marketing Pages: Explore /src/app/(marketing) to see the landing page and other marketing content
- API Routes: Look at the controllers in various feature modules to understand the API structure
Creating Your First Feature
Let's walk through creating your first feature using Igniter.js. We'll create a simple "Notes" feature as an example, following our multi-tenant architecture where organizations are the main tenants.
Step 1: Define the Database Schema
First, define your feature's data model in the Prisma schema. Note that we relate entities to organizations, not users:
// prisma/schema.prisma
model Note {
id String @id @default(cuid())
title String
content String
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Run the migration to update your database:
bun db:migrate:dev --name add_notes
Step 2: Generate the Feature
Using the Igniter.js CLI, generate a new feature scaffold:
bun igniter generate feature
Select "Notes" from the available options based on your Prisma schema. This will create:
- Types and interfaces
- Controllers that handle HTTP requests
- Procedures that contain business logic
- API routes with proper permissions
Step 3: Understanding Generated Files
Let's look at the key components:
Types (notes.interfaces.ts)
export interface Note {
id: string
title: string
content: string
organizationId: string
organization?: Organization
createdAt: Date
updatedAt: Date
}
export interface CreateNoteDTO {
title: string
content: string
organizationId: string
}
export interface NoteQueryParams {
page?: number
limit?: number
sortBy?: string
sortOrder?: 'asc' | 'desc'
search?: string
organizationId: string
}
Controller with Permissions (notes.controller.ts)
export const NotesController = igniter.controller({
name: 'notes',
path: '/notes',
actions: {
findMany: igniter.query({
method: 'GET',
path: '/',
use: [NotesFeatureProcedure(), AuthFeatureProcedure()],
handler: async ({ response, request, context }) => {
const auth = await context.auth.getSession({
requirements: 'authenticated',
roles: ['admin', 'owner', 'member']
})
const result = await context.notes.findMany({
...request.query,
organizationId: auth.organization.id
})
return response.success(result)
}
})
}
})
Procedure (notes.procedure.ts)
export const NotesFeatureProcedure = igniter.procedure({
name: 'NotesFeatureProcedure',
handler: async (_, { context }) => {
return {
notes: {
findMany: async (query: NoteQueryParams): Promise<Note[]> => {
const result = await context.providers.database.note.findMany({
where: query.search ? {
OR: [
{ title: { contains: query.search } },
{ content: { contains: query.search } }
],
organizationId: query.organizationId
} : {
organizationId: query.organizationId
},
skip: query.page ? (query.page - 1) * (query.limit || 10) : undefined,
take: query.limit,
orderBy: query.sortBy ? { [query.sortBy]: query.sortOrder || 'asc' } : undefined
})
return result as Note[]
}
}
}
}
})
Step 4: Understanding the Architecture
- Multi-tenant Design: All features are scoped to organizations, not individual users
- Permission System:
- Use AuthFeatureProcedure() to handle authentication
- Define required roles (admin, owner, member)
- Access organization context via auth.organization.id
- Separation of Concerns:
- Controllers: Handle HTTP requests and permissions
- Procedures: Contain business logic and interact with providers
- Providers: Handle database operations and external services
Step 5: Register the Feature
Register your feature in the router:
// src/features/notes/notes.router.ts
import { NotesController } from './controllers/notes.controller'
export const notesRouter = {
controllers: [NotesController]
}
Next Steps
- Explore existing features like lead to understand best practices
- Implement client-side components using the generated API
- Add custom business logic in procedures
- Extend the permission system for your specific needs
Using Your Feature
Let's look at some examples of how to use your feature in different contexts:
Client-Side Form Example
// src/features/notes/presentation/components/note-upsert-sheet.tsx
'use client'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { api } from '@/igniter.client'
const schema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(1, 'Content is required')
})
export function NoteUpsertSheet() {
const form = useForm({
resolver: zodResolver(schema)
})
const { mutate: createNote } = api.notes.create.useMutation({
onSuccess: () => {
form.reset()
}
})
const onSubmit = form.handleSubmit((data) => {
createNote(data)
})
return (
<form onSubmit={onSubmit}>
<input {...form.register('title')} />
<textarea {...form.register('content')} />
<button type="submit">Create Note</button>
</form>
)
}
Client-Side Query Example
// src/app/(dashboard)/app/organizations/page.tsx
'use client'
import { api } from '@/igniter.client'
export default function OrganizationSwitcher() {
const { data: organizations } = api.organization.findMany.useQuery()
return (
<div>
{organizations?.map((org) => (
<div key={org.id}>
{org.name}
</div>
))}
</div>
)
}
Server-Side Query Example
// src/app/(dashboard)/app/leads/page.tsx
import { api } from '@/igniter.server'
export default async function LeadsPage() {
const contacts = await api.lead.findMany.query()
return (
<LeadDataTableProvider initialData={contacts.data ?? []}>
<PageWrapper>
<PageHeader>
<PageTitle>Leads</PageTitle>
</PageHeader>
</PageWrapper>
</LeadDataTableProvider>
)
}
Check out other guides to learn more about:
- Role-based access control
- Organization management
- Custom providers and services
Quick Tip
Always implement proper error handling and reconnection logic in your SSE clients to ensure a robust user experience.