Complete Type Safety Guide for Next.js and Supabase with TypeScript
Complete guide to type safety in Next.js with Supabase. Learn database type generation, Zod validation, type-safe queries, and production TypeScript patterns.
Complete Type Safety Guide for Next.js and Supabase with TypeScript#
TypeScript catches errors at compile time. But without proper setup, your Supabase queries are just strings with any types. You lose autocomplete, type checking, and the safety TypeScript promises.
This guide shows you how to achieve end-to-end type safety: from database schema to UI components, with runtime validation and compile-time guarantees.
Prerequisites#
- Next.js 14+ with TypeScript
- Supabase project
- Supabase CLI installed
- Basic TypeScript knowledge
Why Type Safety Matters#
Without types:
// ❌ No type safety
const { data } = await supabase
.from('posts')
.select('*')
// data is any - no autocomplete, no type checking
console.log(data[0].titl) // Typo not caught!With types:
// ✅ Full type safety
const { data } = await supabase
.from('posts')
.select('*')
// data is Post[] - autocomplete works, typos caught
console.log(data[0].title) // ✓
console.log(data[0].titl) // Error: Property 'titl' does not existGenerating Database Types#
Generate TypeScript types from your database schema:
npx supabase gen types typescript --project-id your-project-id > types/database.types.tsOr from local database:
npx supabase gen types typescript --local > types/database.types.tsThis creates:
// types/database.types.ts
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export interface Database {
public: {
Tables: {
posts: {
Row: {
id: string
title: string
content: string | null
user_id: string
published: boolean
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
content?: string | null
user_id: string
published?: boolean
created_at?: string
updated_at?: string
}
Update: {
id?: string
title?: string
content?: string | null
user_id?: string
published?: boolean
created_at?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "posts_user_id_fkey"
columns: ["user_id"]
referencedRelation: "users"
referencedColumns: ["id"]
}
]
}
// ... other tables
}
Views: {
// ... views
}
Functions: {
// ... functions
}
Enums: {
// ... enums
}
}
}Three types per table:
- Row: Data returned from SELECT
- Insert: Data for INSERT operations
- Update: Data for UPDATE operations
Type-Safe Supabase Client#
Create a typed client:
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@/types/database.types'
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}Server client:
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import type { Database } from '@/types/database.types'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
},
}
)
}Now all queries are type-safe:
const supabase = createClient()
// ✅ Type-safe query
const { data } = await supabase
.from('posts') // Autocomplete shows all tables
.select('title, content, user_id') // Autocomplete shows all columns
.eq('published', true) // Type-checked
// data is inferred as:
// { title: string; content: string | null; user_id: string }[]Type-Safe Queries#
Basic Queries#
// Select all columns
const { data: posts } = await supabase
.from('posts')
.select('*')
// posts: Database['public']['Tables']['posts']['Row'][]
// Select specific columns
const { data: titles } = await supabase
.from('posts')
.select('id, title')
// titles: { id: string; title: string }[]
// With filters
const { data: published } = await supabase
.from('posts')
.select('*')
.eq('published', true)
.order('created_at', { ascending: false })
// published: Database['public']['Tables']['posts']['Row'][]Joins#
// Join with profiles
const { data: postsWithAuthors } = await supabase
.from('posts')
.select(`
*,
profiles (
name,
avatar_url
)
`)
// Type inferred:
// {
// ...Post,
// profiles: {
// name: string
// avatar_url: string | null
// } | null
// }[]Insert#
const { data: newPost, error } = await supabase
.from('posts')
.insert({
title: 'My Post',
content: 'Content here',
user_id: userId,
// published is optional (has default)
})
.select()
.single()
// newPost: Database['public']['Tables']['posts']['Row']
// ❌ Type error - missing required field
await supabase.from('posts').insert({
title: 'My Post'
// Error: Property 'user_id' is missing
})
// ❌ Type error - wrong type
await supabase.from('posts').insert({
title: 123 // Error: Type 'number' is not assignable to type 'string'
})Update#
const { data: updated } = await supabase
.from('posts')
.update({
title: 'Updated Title',
// All fields optional in Update type
})
.eq('id', postId)
.select()
.single()
// updated: Database['public']['Tables']['posts']['Row']Delete#
const { error } = await supabase
.from('posts')
.delete()
.eq('id', postId)Helper Types#
Extract types for use in your application:
// types/index.ts
import type { Database } from './database.types'
// Table row types
export type Post = Database['public']['Tables']['posts']['Row']
export type Profile = Database['public']['Tables']['profiles']['Row']
export type Comment = Database['public']['Tables']['comments']['Row']
// Insert types
export type PostInsert = Database['public']['Tables']['posts']['Insert']
export type ProfileInsert = Database['public']['Tables']['profiles']['Insert']
// Update types
export type PostUpdate = Database['public']['Tables']['posts']['Update']
// Join types
export type PostWithAuthor = Post & {
profiles: Pick<Profile, 'name' | 'avatar_url'> | null
}
export type PostWithComments = Post & {
comments: Comment[]
}Use in components:
// components/PostCard.tsx
import type { Post } from '@/types'
export function PostCard({ post }: { post: Post }) {
return (
<article>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
)
}Runtime Validation with Zod#
TypeScript provides compile-time safety. Zod provides runtime validation:
npm install zodCreate schemas matching your database types:
// lib/validations/post.ts
import { z } from 'zod'
export const PostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10).max(5000),
published: z.boolean().default(false),
user_id: z.string().uuid()
})
export const PostInsertSchema = PostSchema.omit({ user_id: true })
export const PostUpdateSchema = PostSchema.partial()
// Infer TypeScript types from Zod schemas
export type PostInput = z.infer<typeof PostInsertSchema>
export type PostUpdateInput = z.infer<typeof PostUpdateSchema>Use in Server Actions:
// actions/posts.ts
'use server'
import { PostInsertSchema } from '@/lib/validations/post'
import { createClient } from '@/lib/supabase/server'
export async function createPost(formData: FormData) {
// Parse and validate
const result = PostInsertSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on'
})
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors
}
}
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'Unauthorized' }
}
const { data, error } = await supabase
.from('posts')
.insert({
...result.data,
user_id: user.id
})
.select()
.single()
if (error) {
return { error: error.message }
}
return { success: true, data }
}Type-Safe Forms#
Combine React Hook Form with Zod:
npm install react-hook-form @hookform/resolvers// components/PostForm.tsx
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { PostInsertSchema, type PostInput } from '@/lib/validations/post'
export function PostForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<PostInput>({
resolver: zodResolver(PostInsertSchema)
})
async function onSubmit(data: PostInput) {
const formData = new FormData()
formData.append('title', data.title)
formData.append('content', data.content)
formData.append('published', String(data.published))
await createPost(formData)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
{...register('title')}
/>
{errors.title && (
<p className="text-red-500">{errors.title.message}</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
{...register('content')}
/>
{errors.content && (
<p className="text-red-500">{errors.content.message}</p>
)}
</div>
<div>
<label>
<input type="checkbox" {...register('published')} />
Publish immediately
</label>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}Type-Safe RPC Calls#
Generate types for Postgres functions:
-- Database function
CREATE FUNCTION get_user_stats(user_id UUID)
RETURNS JSON
LANGUAGE plpgsql
AS $$
BEGIN
RETURN json_build_object(
'post_count', (SELECT COUNT(*) FROM posts WHERE posts.user_id = get_user_stats.user_id),
'comment_count', (SELECT COUNT(*) FROM comments WHERE comments.user_id = get_user_stats.user_id)
);
END;
$$;After regenerating types:
const { data } = await supabase
.rpc('get_user_stats', { user_id: userId })
// data is typed based on function return typeFor complex return types, define manually:
// types/index.ts
export type UserStats = {
post_count: number
comment_count: number
}
// Usage
const { data } = await supabase
.rpc('get_user_stats', { user_id: userId })
const stats = data as UserStatsType-Safe Realtime#
Type realtime subscriptions:
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { Post } from '@/types'
export function RealtimePosts() {
const [posts, setPosts] = useState<Post[]>([])
const supabase = createClient()
useEffect(() => {
const channel = supabase
.channel('posts')
.on<Post>(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'posts'
},
(payload) => {
// payload.new is typed as Post
setPosts(current => [payload.new, ...current])
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [supabase])
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
</article>
))}
</div>
)
}Enum Types#
Define enums in database:
CREATE TYPE post_status AS ENUM ('draft', 'published', 'archived');
ALTER TABLE posts
ADD COLUMN status post_status DEFAULT 'draft';Generated types include enums:
// types/database.types.ts
export interface Database {
public: {
Enums: {
post_status: 'draft' | 'published' | 'archived'
}
}
}Use in your code:
import type { Database } from '@/types/database.types'
type PostStatus = Database['public']['Enums']['post_status']
const status: PostStatus = 'published' // ✓
const invalid: PostStatus = 'pending' // ErrorAutomating Type Generation#
Regenerate types after schema changes:
// package.json
{
"scripts": {
"types:generate": "npx supabase gen types typescript --local > types/database.types.ts",
"types:generate:remote": "npx supabase gen types typescript --project-id your-project-id > types/database.types.ts"
}
}Run after migrations:
npx supabase db reset
npm run types:generateOr use a git hook:
# .husky/post-merge
#!/bin/sh
if git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep --quiet "supabase/migrations"
then
npm run types:generate
fiCommon Pitfalls#
1. Not Regenerating Types#
// ❌ Added column to database but didn't regenerate types
const { data } = await supabase
.from('posts')
.select('*')
console.log(data[0].new_column) // No autocomplete, no type checkingAlways regenerate after schema changes.
2. Using any Types#
// ❌ Losing type safety
const data: any = await supabase.from('posts').select('*')
// ✅ Keep types
const { data } = await supabase.from('posts').select('*')3. Not Validating User Input#
// ❌ No runtime validation
export async function createPost(data: any) {
await supabase.from('posts').insert(data)
}
// ✅ Validate with Zod
export async function createPost(data: unknown) {
const validated = PostSchema.parse(data)
await supabase.from('posts').insert(validated)
}4. Incorrect Type Assertions#
// ❌ Unsafe assertion
const post = data as Post
// ✅ Validate first
const post = PostSchema.parse(data)Advanced Patterns#
Generic Query Builder#
// lib/queries.ts
import type { Database } from '@/types/database.types'
type Tables = Database['public']['Tables']
type TableName = keyof Tables
export async function findById<T extends TableName>(
supabase: SupabaseClient<Database>,
table: T,
id: string
): Promise<Tables[T]['Row'] | null> {
const { data } = await supabase
.from(table)
.select('*')
.eq('id', id)
.single()
return data
}
// Usage
const post = await findById(supabase, 'posts', postId)
// post is typed as Post | nullType-Safe Query Filters#
type QueryFilter<T> = {
[K in keyof T]?: T[K] | { operator: 'eq' | 'neq' | 'gt' | 'lt'; value: T[K] }
}
export async function findWhere<T extends TableName>(
supabase: SupabaseClient<Database>,
table: T,
filters: QueryFilter<Tables[T]['Row']>
): Promise<Tables[T]['Row'][]> {
let query = supabase.from(table).select('*')
for (const [key, value] of Object.entries(filters)) {
if (typeof value === 'object' && 'operator' in value) {
query = query[value.operator](key, value.value)
} else {
query = query.eq(key, value)
}
}
const { data } = await query
return data || []
}
// Usage
const posts = await findWhere(supabase, 'posts', {
published: true,
view_count: { operator: 'gt', value: 100 }
})Summary#
Complete type safety in Next.js with Supabase requires:
- Generate TypeScript types from database schema
- Use typed Supabase clients
- Validate user input with Zod at runtime
- Regenerate types after schema changes
- Combine TypeScript and Zod for compile-time and runtime safety
This approach catches errors early, provides excellent developer experience with autocomplete, and prevents invalid data from reaching your database.
Next.js Server Actions with Supabase: Complete Production Guide Database Design and Optimization for Next.js and Supabase Applications Supabase Postgres Functions and Triggers: Complete Developer Guide
- Next.js + Supabase production resource hub
- Complete Guide to Building SaaS with Next.js and Supabase
- Deploying Next.js + Supabase to Production
- The Complete Next.js + Supabase Production Launch Checklist (47 Items)
- Next.js Data Fetching Patterns with Supabase: Server Components, Streaming, and Caching
Related#
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Next.js Server Actions with Supabase: Complete Guide
Complete guide to Next.js Server Actions with Supabase. Learn validation, error handling, optimistic updates, and production patterns for type-safe forms.
GraphQL Integration with Next.js and Supabase Guide
Learn how to integrate GraphQL with Next.js and Supabase. Complete tutorial covering schema generation, resolvers, authentication, and advanced patterns for production apps.
Mastering Supabase Edge Functions with Next.js
Complete guide to building and deploying Supabase Edge Functions with Next.js. Learn serverless functions, Deno runtime, database triggers, webhooks, scheduled jobs, and real-world use cases.