Creating Features

Creating Features

Step-by-step guide for adding new features to your project. Use the built-in habits feature as a reference implementation.


1. Database Schema

Create lib/db/schema/{feature}.ts:

import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
import { tenants } from './tenants';

export const habits = pgTable('habits', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id')
    .notNull()
    .references(() => tenants.id, { onDelete: 'cascade' }),
  name: text('name').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true })
    .notNull()
    .defaultNow(),
});

2. Generate Migration

npm run db:generate

This creates a new SQL file in supabase/migrations/.

3. Add RLS Policies

Open the generated migration file and add RLS policies at the end:

ALTER TABLE habits ENABLE ROW LEVEL SECURITY;

CREATE POLICY "habits_select" ON habits FOR SELECT USING (
  EXISTS (SELECT 1 FROM tenant_users
    WHERE tenant_id = habits.tenant_id AND user_id = auth.uid())
);

CREATE POLICY "habits_insert" ON habits FOR INSERT WITH CHECK (
  EXISTS (SELECT 1 FROM tenant_users
    WHERE tenant_id = habits.tenant_id AND user_id = auth.uid())
);

CREATE POLICY "habits_update" ON habits FOR UPDATE USING (
  EXISTS (SELECT 1 FROM tenant_users
    WHERE tenant_id = habits.tenant_id AND user_id = auth.uid())
) WITH CHECK (
  EXISTS (SELECT 1 FROM tenant_users
    WHERE tenant_id = habits.tenant_id AND user_id = auth.uid())
);

CREATE POLICY "habits_delete" ON habits FOR DELETE USING (
  EXISTS (SELECT 1 FROM tenant_users
    WHERE tenant_id = habits.tenant_id AND user_id = auth.uid())
);

4. Apply Migration

npm run db:migrate

5. Queries

Create lib/db/queries/{feature}.ts:

import { habits } from '../schema/habits';
import { desc, eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';

export async function getHabits(db: NodePgDatabase, tenantId: string) {
  return db
    .select()
    .from(habits)
    .where(eq(habits.tenantId, tenantId))
    .orderBy(desc(habits.createdAt));
}

export async function createHabit(db: NodePgDatabase, input: {...}) {
  const [habit] = await db.insert(habits).values(input).returning();
  return habit;
}

Important: Always filter by tenantId for tenant-scoped entities.

6. Validation

Create lib/validation/{feature}Schemas.ts:

import { z } from 'zod';

export const createHabitSchema = z.object({
  name: z.string().min(1).max(255),
});

export type CreateHabitInput = z.infer<typeof createHabitSchema>;

7. API Routes

Create app/api/{feature}/route.ts:

import { requireUser } from '@/lib/auth/requireUser';
import { withAuthDb } from '@/lib/db/withAuthDb';
import { ok, fail, created } from '@/lib/api/responses';
import { getActiveTenantId } from '@/lib/tenants/activeTenant';

export async function GET() {
  const { user, session } = await requireUser();
  if (!user || !session) return fail('UNAUTHENTICATED', 'Auth required', 401);

  const tenantId = await getActiveTenantId();
  if (!tenantId) return fail('BAD_REQUEST', 'No tenant', 400);

  const data = await withAuthDb(session.access_token, (db) =>
    getHabits(db, tenantId)
  );
  return ok(data);
}

export async function POST(req: Request) {
  const { user, session } = await requireUser();
  if (!user || !session) return fail('UNAUTHENTICATED', 'Auth required', 401);

  const body = await req.json();
  const parsed = schema.safeParse(body);
  if (!parsed.success) return fail('VALIDATION_ERROR', 'Invalid input', 400);

  const tenantId = await getActiveTenantId();
  if (!tenantId) return fail('BAD_REQUEST', 'No tenant', 400);

  const result = await withAuthDb(session.access_token, (db) =>
    createHabit(db, { ...parsed.data, tenantId })
  );

  return created(result);
}

8. Client API

Create lib/api/client/{feature}.ts:

export async function fetchHabits() {
  const res = await fetch('/api/habits', { cache: 'no-store' });
  if (!res.ok) throw new Error('Failed');
  return (await res.json()).data;
}

9. React Query Hooks

Create hooks/use{Feature}.ts:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/store/appStore';

// Include tenantId in queryKey for proper cache isolation
export const habitKeys = {
  all: (tenantId: string | null) => ['habits', tenantId] as const,
};

export function useHabits() {
  const activeTenant = useAppStore((s) => s.activeTenant);

  return useQuery({
    queryKey: habitKeys.all(activeTenant?.id ?? null),
    queryFn: fetchHabits,
    enabled: !!activeTenant,
  });
}

export function useCreateHabit() {
  const qc = useQueryClient();
  const activeTenant = useAppStore((s) => s.activeTenant);

  return useMutation({
    mutationFn: createHabit,
    onSuccess: () => qc.invalidateQueries({
      queryKey: habitKeys.all(activeTenant?.id ?? null)
    }),
  });
}

10. UI Components

Create components/{feature}/:

  • {Feature}Card.tsx — display single item
  • {Feature}Form.tsx — create/edit form (react-hook-form + Zod)
  • {Feature}List.tsx — list with CRUD operations

11. Page

Create app/(admin)/{feature}/page.tsx:

import { FeatureList } from '@/components/feature/FeatureList';

export default function FeaturePage() {
  return <FeatureList />;
}

12. Navigation

Add to components/navigations/app-sidebar.tsx:

const navItems = [
  { title: 'Feature', href: '/feature', icon: Icon },
];

13. Dialogs (Optional)

If the feature needs create/edit dialogs:

1. Add dialog types to utils/constants.ts:

export const DIALOG_TYPES = {
  HABIT: {
    CREATE: 'habit:create',
    EDIT: 'habit:edit',
  },
} as const;

2. Create components/dialogs/{Feature}Dialog.tsx

3. Add case to DialogManager.tsx:

case DIALOG_TYPES.HABIT.CREATE:
case DIALOG_TYPES.HABIT.EDIT:
  return <HabitDialog />;

4. Open from any component:

import { openDialog } from '@/store/useDialogStore';
openDialog(DIALOG_TYPES.HABIT.CREATE);
openDialog(DIALOG_TYPES.HABIT.EDIT, { habit: data });

Checklist

Use this checklist when adding any new feature:

  • Schema with tenantId
  • Run npm run db:generate
  • Add RLS policies to migration
  • Run npm run db:migrate
  • Queries (filter by tenantId)
  • Validation (Zod schemas)
  • API routes (following the pattern)
  • Client API functions
  • React Query hooks (tenantId in queryKey)
  • UI components
  • Dialogs (if needed)
  • Page
  • Navigation link

On this page