SupaLaunch is a SaaS boilerplate built using Supabase and Next.js. It includes authentication, Stripe payments, Postgres database, 20+ TailwindCSS themes, emails, OpenAI API streaming, file storage and more.
Working with databases in TypeScript can be tricky - you want to ensure your application knows exactly what data it's dealing with. Supabase makes this easy by automatically generating TypeScript types from your database schema. Let's explore how to set this up and use it effectively.
First, you'll need to install the Supabase CLI. This tool is essential for generating TypeScript types from your database schema. The CLI provides commands to interact with your Supabase project, including type generation capabilities.
npm install supabase@">=1.8.1" --save-dev
The version 1.8.1 or higher is required as it includes important improvements to type generation. Install it as a dev dependency since we'll only use it during development.
Before generating types, you need to authenticate with your Supabase account and initialize your project. This process creates necessary configuration files and establishes a connection to your Supabase project:
# Login to Supabase CLI
npx supabase login
# Initialize your project
npx supabase init
The login command will open your browser to authenticate with Supabase. The init command creates a supabase/ directory in your project with configuration files.
There are two ways to generate types: from your remote project or local development database. Choose the method that best fits your workflow:
This method pulls the schema from your deployed Supabase project. It's useful when working with an existing database or when multiple developers need to stay in sync with the production schema:
npx supabase gen types typescript --project-id "your-project-id" > database.types.ts
If you're developing locally with a Supabase instance, this method generates types from your local database. It's perfect for rapid development and testing schema changes:
npx supabase gen types typescript --local > database.types.ts
Let's look at a practical example using a common task management system. Here's a SQL schema that defines a tasks table with common fields you might use:
create table public.tasks (
id bigint generated always as identity primary key,
title text not null,
is_completed boolean default false,
user_id uuid references auth.users,
created_at timestamp with time zone default timezone('utc'::text, now())
);
When you run the type generation command, Supabase will analyze this schema and generate corresponding TypeScript types. The generated types include three main interfaces for each table: Row (for reading), Insert (for creating), and Update (for modifying records):
export interface Database {
public: {
Tables: {
tasks: {
Row: {
id: number
title: string
is_completed: boolean
user_id: string | null
created_at: string
}
Insert: {
id?: never // Generated column
title: string
is_completed?: boolean
user_id?: string | null
created_at?: string
}
Update: {
id?: never
title?: string
is_completed?: boolean
user_id?: string | null
created_at?: string
}
}
}
}
}
Notice how the types reflect the SQL schema:
Row
type includes all fields as they appear in the databaseInsert
type makes optional fields that have defaultsUpdate
type makes all fields optional since you might want to update only specific fieldsid
field is marked as never
for Insert and Update since it's auto-generatedTo start using the generated types, you need to properly configure your Supabase client. By passing the Database type as a generic parameter, you enable TypeScript to provide type checking for all database operations:
import { createClient } from '@supabase/supabase-js'
import { Database } from './database.types'
const supabase = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
The generic parameter <Database>
tells TypeScript about your database structure, enabling autocomplete and type checking for all queries.
Now you can write type-safe queries that TypeScript will validate at compile time. Here are some common patterns you'll use:
// Fetching tasks
const getTasks = async () => {
const { data, error } = await supabase
.from('tasks')
.select()
if (error) throw error
// data is typed as Task[] automatically!
return data
}
// Creating a new task
const createTask = async (title: string) => {
const { data, error } = await supabase
.from('tasks')
.insert({
title,
// TypeScript will ensure we're not sending invalid fields
})
.select()
.single()
if (error) throw error
return data
}
In these examples:
getTasks
function automatically knows the return type includes all task fieldscreateTask
validates that we're only inserting valid fieldsWhen your database has relationships between tables, you'll often need to join them in queries. Here's how to handle types for related data:
create table public.categories (
id bigint generated always as identity primary key,
name text not null
);
alter table public.tasks add column category_id bigint references public.categories;
After adding the relationship, you'll need to define types for joined queries. This ensures TypeScript understands the shape of the combined data:
// Define the type for the joined result
type TaskWithCategory = Database['public']['Tables']['tasks']['Row'] & {
categories: Database['public']['Tables']['categories']['Row']
}
// Fetch tasks with categories
const getTasksWithCategories = async () => {
const { data, error } = await supabase
.from('tasks')
.select(`
*,
categories (
id,
name
)
`)
if (error) throw error
// data is now typed as TaskWithCategory[]
return data
}
The TaskWithCategory
type combines the task fields with the category fields, providing full type safety for joined queries. This helps catch common errors like:
Supabase generates several type helpers that can make your code more concise and maintainable. Instead of writing out full type paths, you can use these shortcuts:
import { Tables, Enums } from './database.types'
// Instead of Database['public']['Tables']['tasks']['Row']
type Task = Tables<'tasks'>
// For enum types if you have any
type UserRole = Enums<'user_role'>
These helpers provide the same type safety but with much cleaner syntax. They're especially useful when you need to reference types in multiple places throughout your codebase.
TypeScript's null checking is particularly valuable when working with optional database fields. Here's how to safely handle nullable relationships:
const updateTaskUser = async (taskId: number, userId: string | null) => {
const { data, error } = await supabase
.from('tasks')
.update({ user_id: userId }) // TypeScript ensures userId can be null
.eq('id', taskId)
.select()
.single()
if (error) throw error
return data
}
This pattern ensures that:
As your database schema evolves, you'll need to keep your TypeScript types in sync. Add a convenient npm script to make this process easier:
# Add this to your package.json scripts
"update-types": "npx supabase gen types typescript --project-id \"$PROJECT_ID\" > database.types.ts"
Now team members can simply run npm run update-types
whenever the schema changes.
For larger teams, automating type updates can prevent synchronization issues. This GitHub Action checks for schema changes daily:
name: Update Database Types
on:
schedule:
- cron: '0 0 * * *' # Runs daily at midnight
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- run: npm run update-types
- name: Commit changes
uses: EndBug/add-and-commit@v7
with:
message: 'chore: update database types'
add: 'database.types.ts'
This automation helps ensure that:
JSON columns require special attention since they can contain complex nested data structures. Here's how to properly type them:
interface TaskMetadata {
priority: 'low' | 'medium' | 'high'
tags: string[]
}
// Extend the generated types
declare module './database.types' {
interface Database {
public: {
Tables: {
tasks: {
Row: {
metadata: TaskMetadata | null
}
Insert: {
metadata?: TaskMetadata | null
}
Update: {
metadata?: TaskMetadata | null
}
}
}
}
}
}
This approach provides several benefits:
When using Row Level Security (RLS), it's important to type your auth context to ensure policy consistency:
type AuthContext = {
user_id: string
role: 'user' | 'admin'
}
const getUserTasks = async (context: AuthContext) => {
// RLS will use the user_id automatically
const { data, error } = await supabase
.from('tasks')
.select()
return { data, error }
}
This pattern helps you:
Using TypeScript with Supabase transforms database interactions into a type-safe, developer-friendly experience. The automatic type generation and rich tooling help catch errors early, improve code quality, and enhance team collaboration.
Remember these key points:
By following these practices, you'll create more reliable applications with fewer runtime errors and better maintainability.
These resources will help you dive deeper into specific aspects of using TypeScript with Supabase and stay updated with best practices.