Published on 2025-03-08

First Steps

Published on

2025-03-08

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:

  1. Authentication Flow: Navigate to /src/saas-boilerplate/features/auth to see how authentication is implemented
  2. Dashboard Pages: Check the /src/app/(dashboard) directory to see the dashboard UI
  3. Marketing Pages: Explore /src/app/(marketing) to see the landing page and other marketing content
  4. 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
// 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:

bash
bun db:migrate:dev --name add_notes

Step 2: Generate the Feature

Using the Igniter.js CLI, generate a new feature scaffold:

bash
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)

typescript
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)

typescript
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)

typescript
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

  1. Multi-tenant Design: All features are scoped to organizations, not individual users
  2. Permission System:
    • Use AuthFeatureProcedure() to handle authentication
    • Define required roles (admin, owner, member)
    • Access organization context via auth.organization.id
  3. 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:

typescript
// src/features/notes/notes.router.ts
import { NotesController } from './controllers/notes.controller'

export const notesRouter = {
  controllers: [NotesController]
}

Next Steps

  1. Explore existing features like lead to understand best practices
  2. Implement client-side components using the generated API
  3. Add custom business logic in procedures
  4. 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

tsx
// 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

tsx
// 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

tsx
// 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
AD

Quick Tip

Always implement proper error handling and reconnection logic in your SSE clients to ensure a robust user experience.

You might also like