--- status: "published" label: "Extending the DB Schema" order: 2 title: "Extending the Database Schema in Next.js Supabase" description: "Learn how to create new migrations and update the database schema in your Next.js Supabase application" --- {% sequence title="Steps to create a new migration" description="Learn how to create new migrations and update the database schema in your Next.js Supabase application" %} [Planning Your Schema Extension](#planning-your-schema-extension) [Creating Schema Files](#creating-schema-files) [Permissions and Access Control](#permissions-and-access-control) [Building Tables with RLS](#building-tables-with-rls) [Advanced Patterns](#advanced-patterns) [Testing and Deployment](#testing-and-deployment) {% /sequence %} This guide walks you through extending Makerkit's database schema with new tables and features. We'll use a comprehensive example that demonstrates best practices, security patterns, and integration with Makerkit's multi-tenant architecture. ## Planning Your Schema Extension Before writing any SQL, it's crucial to understand how your new features fit into Makerkit's multi-tenant architecture. ### Decision Framework **Step 1: Determine Data Ownership** Ask yourself: "Who owns this data - individual users or accounts?" - **User-owned data**: Personal preferences, activity logs, user settings - **Account-owned data**: Business content, shared resources, collaborative features **Step 2: Define Access Patterns** - **Public within account**: All team members can access - **Private within account**: Only creator + specific permissions - **Admin-only**: Requires special permissions or super admin access **Step 3: Consider Integration Points** - Does this feature affect billing? (usage tracking, feature gates) - Does it need notifications? (in-app alerts, email triggers) - Should it have audit trails? (compliance, change tracking) ## Creating Schema Files Makerkit organizes database schema in numbered files for proper ordering. Follow this workflow: ### 1. Create Your Schema File ```bash # Create a new schema file with the next number touch apps/web/supabase/schemas/18-notes-feature.sql ``` ### 2. Apply Development Workflow ```bash # Start Supabase pnpm supabase:web:start # Create migration from your schema file pnpm --filter web run supabase:db:diff -f notes-feature # Restart with new schema pnpm supabase:web:reset # Generate TypeScript types pnpm supabase:web:typegen ``` ## Permissions and Access Control ### Adding New Permissions Makerkit defines permissions in the `public.app_permissions` enum. Add feature-specific permissions: ```sql -- Add new permissions for your feature ALTER TYPE public.app_permissions ADD VALUE 'notes.create'; ALTER TYPE public.app_permissions ADD VALUE 'notes.manage'; ALTER TYPE public.app_permissions ADD VALUE 'notes.delete'; COMMIT; ``` **Note:** The Supabase diff function does not support adding new permissions to enum types. Please add the new permissions manually instead of using the diff function. **Permission Naming Convention**: Use the pattern `resource.action` for consistency: - `notes.create` - Create new notes - `notes.manage` - Edit existing notes - `notes.delete` - Delete notes - `notes.share` - Share with external users ### Role Assignment Consider which roles should have which permissions by default: ```sql -- Grant permissions to roles INSERT INTO public.role_permissions (role, permission) VALUES ('owner', 'notes.create'), ('owner', 'notes.manage'), ('owner', 'notes.delete'), ('owner', 'notes.share'), ('member', 'notes.create'), ('member', 'notes.manage'); ``` ## Building Tables with RLS Let's create a comprehensive notes feature that demonstrates various patterns and best practices. ### Core Notes Table ```sql -- Create the main notes table with all standard fields CREATE TABLE IF NOT EXISTS public.notes ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, title varchar(500) NOT NULL, content text, is_published boolean NOT NULL DEFAULT false, tags text[] DEFAULT '{}', metadata jsonb DEFAULT '{}', -- Audit fields (always include these) created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), created_by uuid REFERENCES auth.users(id), updated_by uuid REFERENCES auth.users(id), -- Data integrity constraints CONSTRAINT notes_title_length CHECK (length(title) >= 1), CONSTRAINT notes_account_required CHECK (account_id IS NOT NULL) ); -- Add helpful comments for documentation COMMENT ON TABLE public.notes IS 'User-generated notes with sharing capabilities'; COMMENT ON COLUMN public.notes.account_id IS 'Account that owns this note (multi-tenant isolation)'; COMMENT ON COLUMN public.notes.is_published IS 'Whether note is visible to all account members'; COMMENT ON COLUMN public.notes.tags IS 'Searchable tags for categorization'; COMMENT ON COLUMN public.notes.metadata IS 'Flexible metadata (view preferences, etc.)'; ``` ### Performance Indexes Consider creating indexes for your query patterns if you are scaling to a large number of records. ```sql -- Essential indexes for performance CREATE INDEX idx_notes_account_id ON public.notes(account_id); CREATE INDEX idx_notes_created_at ON public.notes(created_at DESC); CREATE INDEX idx_notes_account_created ON public.notes(account_id, created_at DESC); CREATE INDEX idx_notes_published ON public.notes(account_id, is_published) WHERE is_published = true; CREATE INDEX idx_notes_tags ON public.notes USING gin(tags); ``` ### Security Setup ```sql -- Always enable RLS (NEVER skip this!) ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY; -- Revoke default permissions and grant explicitly REVOKE ALL ON public.notes FROM authenticated, service_role; GRANT SELECT, INSERT, UPDATE, DELETE ON public.notes TO authenticated, service_role; ``` ### RLS Policies Create comprehensive policies that handle both personal and team accounts: ```sql -- SELECT policy: Read published notes or own private notes CREATE POLICY "notes_select" ON public.notes FOR SELECT TO authenticated USING ( -- Personal account: direct ownership account_id = (SELECT auth.uid()) OR -- Team account: member can read published notes (public.has_role_on_account(account_id) AND is_published = true) OR -- Team account: creator can read their own drafts (public.has_role_on_account(account_id) AND created_by = auth.uid()) OR -- Team account: users with manage permission can read all public.has_permission(auth.uid(), account_id, 'notes.manage') ); -- INSERT policy: Must have create permission CREATE POLICY "notes_insert" ON public.notes FOR INSERT TO authenticated WITH CHECK ( -- Personal account: direct ownership account_id = (SELECT auth.uid()) OR -- Team account: must have create permission public.has_permission(auth.uid(), account_id, 'notes.create') ); -- UPDATE policy: Owner or manager can edit CREATE POLICY "notes_update" ON public.notes FOR UPDATE TO authenticated USING ( -- Personal account: direct ownership account_id = (SELECT auth.uid()) OR -- Team account: creator can edit their own (public.has_role_on_account(account_id) AND created_by = auth.uid()) OR -- Team account: users with manage permission public.has_permission(auth.uid(), account_id, 'notes.manage') ) WITH CHECK ( -- Same conditions for updates account_id = (SELECT auth.uid()) OR (public.has_role_on_account(account_id) AND created_by = auth.uid()) OR public.has_permission(auth.uid(), account_id, 'notes.manage') ); -- DELETE policy: Stricter permissions required CREATE POLICY "notes_delete" ON public.notes FOR DELETE TO authenticated USING ( -- Personal account: direct ownership account_id = (SELECT auth.uid()) OR -- Team account: creator can delete own notes (public.has_role_on_account(account_id) AND created_by = auth.uid()) OR -- Team account: users with delete permission public.has_permission(auth.uid(), account_id, 'notes.delete') ); ``` ### Automatic Triggers Add triggers for common patterns: ```sql -- Automatically update timestamps CREATE TRIGGER notes_updated_at BEFORE UPDATE ON public.notes FOR EACH ROW EXECUTE FUNCTION kit.trigger_set_timestamps(); -- Track who made changes CREATE TRIGGER notes_track_changes BEFORE INSERT OR UPDATE ON public.notes FOR EACH ROW EXECUTE FUNCTION kit.trigger_set_user_tracking(); ``` ## Advanced Patterns ### 1. Hierarchical Notes (Categories) ```sql -- Note categories with hierarchy CREATE TABLE IF NOT EXISTS public.note_categories ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, parent_id uuid REFERENCES public.note_categories(id) ON DELETE CASCADE, name varchar(255) NOT NULL, color varchar(7), -- hex color codes path ltree, -- efficient tree operations created_at timestamptz NOT NULL DEFAULT now(), created_by uuid REFERENCES auth.users(id), -- Ensure hierarchy stays within account CONSTRAINT categories_same_account CHECK ( parent_id IS NULL OR (SELECT account_id FROM public.note_categories WHERE id = parent_id) = account_id ), -- Prevent circular references CONSTRAINT categories_no_self_parent CHECK (id != parent_id) ); -- Link notes to categories ALTER TABLE public.notes ADD COLUMN category_id uuid REFERENCES public.note_categories(id) ON DELETE SET NULL; -- Index for tree operations CREATE INDEX idx_note_categories_path ON public.note_categories USING gist(path); CREATE INDEX idx_note_categories_account ON public.note_categories(account_id, parent_id); ``` ### 2. Note Sharing and Collaboration ```sql -- External sharing tokens CREATE TABLE IF NOT EXISTS public.note_shares ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), note_id uuid NOT NULL REFERENCES public.notes(id) ON DELETE CASCADE, share_token varchar(64) NOT NULL UNIQUE, expires_at timestamptz, password_hash varchar(255), -- optional password protection view_count integer DEFAULT 0, max_views integer, -- optional view limit created_at timestamptz NOT NULL DEFAULT now(), created_by uuid REFERENCES auth.users(id), -- Ensure token uniqueness CONSTRAINT share_token_format CHECK (share_token ~ '^[a-zA-Z0-9_-]{32,64}$') ); -- Function to generate secure share tokens CREATE OR REPLACE FUNCTION generate_note_share_token() RETURNS varchar(64) AS $$ BEGIN RETURN encode(gen_random_bytes(32), 'base64url'); END; $$ LANGUAGE plpgsql; ``` ### 3. Usage Tracking for Billing ```sql -- Track note creation for usage-based billing CREATE TABLE IF NOT EXISTS public.note_usage_logs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, action varchar(50) NOT NULL, -- 'create', 'share', 'export' note_count integer DEFAULT 1, date date DEFAULT CURRENT_DATE, -- Daily aggregation UNIQUE(account_id, action, date) ); -- Function to track note usage CREATE OR REPLACE FUNCTION track_note_usage( target_account_id uuid, usage_action varchar(50) ) RETURNS void AS $$ BEGIN INSERT INTO public.note_usage_logs (account_id, action, note_count) VALUES (target_account_id, usage_action, 1) ON CONFLICT (account_id, action, date) DO UPDATE SET note_count = note_usage_logs.note_count + 1; END; $$ LANGUAGE plpgsql; -- Trigger to track note creation CREATE OR REPLACE FUNCTION trigger_track_note_creation() RETURNS trigger AS $$ BEGIN PERFORM track_note_usage(NEW.account_id, 'create'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER notes_track_creation AFTER INSERT ON public.notes FOR EACH ROW EXECUTE FUNCTION trigger_track_note_creation(); ``` ### 4. Feature Access Control ```sql -- Check if account has access to advanced note features CREATE OR REPLACE FUNCTION has_advanced_notes_access(target_account_id uuid) RETURNS boolean AS $$ DECLARE has_access boolean := false; BEGIN -- Check active subscription with advanced features SELECT EXISTS( SELECT 1 FROM public.subscriptions s JOIN public.subscription_items si ON s.id = si.subscription_id WHERE s.account_id = target_account_id AND s.status = 'active' AND si.price_id IN ('price_pro_plan', 'price_enterprise_plan') ) INTO has_access; RETURN has_access; END; $$ LANGUAGE plpgsql; -- Restrictive policy for advanced features CREATE POLICY "notes_advanced_features" ON public.notes AS RESTRICTIVE FOR ALL TO authenticated USING ( -- Basic features always allowed is_published = true OR category_id IS NULL OR tags = '{}' OR -- Advanced features require subscription has_advanced_notes_access(account_id) ); ``` ## Security Enhancements ### MFA Compliance For sensitive note operations, enforce MFA: ```sql -- Require MFA for note deletion CREATE POLICY "notes_delete_mfa" ON public.notes AS RESTRICTIVE FOR DELETE TO authenticated USING (public.is_mfa_compliant()); ``` ### Super Admin Access Allow super admins to access all notes for support purposes: ```sql -- Super admin read access (for support) CREATE POLICY "notes_super_admin_access" ON public.notes FOR SELECT TO authenticated USING (public.is_super_admin()); ``` ### Rate Limiting Implement basic rate limiting for note creation: ```sql -- Rate limiting: max 100 notes per day per account CREATE OR REPLACE FUNCTION check_note_creation_limit(target_account_id uuid) RETURNS boolean AS $$ DECLARE daily_count integer; BEGIN SELECT COALESCE(note_count, 0) INTO daily_count FROM public.note_usage_logs WHERE account_id = target_account_id AND action = 'create' AND date = CURRENT_DATE; RETURN daily_count < 100; -- Adjust limit as needed END; $$ LANGUAGE plpgsql; -- Policy to enforce rate limiting CREATE POLICY "notes_rate_limit" ON public.notes AS RESTRICTIVE FOR INSERT TO authenticated WITH CHECK (check_note_creation_limit(account_id)); ``` ### Type Generation After schema changes, always update TypeScript types: ```bash # reset the database pnpm supabase:web:reset # Generate new types pnpm supabase:web:typegen # Verify types work in your application pnpm typecheck ``` ## Example Usage in Application With your schema complete, here's how to use it in your application: ```typescript // Server component - automatically inherits RLS protection import { getSupabaseServerClient } from '@kit/supabase/server-client'; async function NotesPage({ params }: { params: Promise<{ account: string }> }) { const { account } = await params; const client = getSupabaseServerClient(); // RLS automatically filters to accessible notes const { data: notes } = await client .from('notes') .select(` *, category:note_categories(name, color), creator:created_by(name, avatar_url) `) .eq('account_id', params.account) .order('created_at', { ascending: false }); return ; } ``` From the client component, you can use the `useQuery` hook to fetch the notes. ```typescript // Client component with real-time updates 'use client'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; function useNotes(accountId: string) { const supabase = useSupabase(); return useQuery({ queryKey: ['notes', accountId], queryFn: async () => { const { data } = await supabase .from('notes') .select('*, category:note_categories(name)') .eq('account_id', accountId); return data; } }); } ``` ## Summary You've now created a comprehensive notes feature that demonstrates: ✅ **Proper multi-tenancy** with account-based data isolation ✅ **Granular permissions** using Makerkit's role system ✅ **Advanced features** like categories, sharing, and usage tracking ✅ **Security best practices** with comprehensive RLS policies ✅ **Performance optimization** with proper indexing ✅ **Integration patterns** with billing and feature gates This pattern can be adapted for any feature in your SaaS application. Remember to always: - Start with proper planning and data ownership decisions - Enable RLS and create comprehensive policies - Add appropriate indexes for your query patterns - Test thoroughly before deploying - Update TypeScript types after schema changes Your database schema is now production-ready and follows Makerkit's security and architecture best practices!