DEV Community

Cover image for Complete Type Safety Guide for Next.js and Supabase with TypeScript
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on • Originally published at iloveblogs.blog

Complete Type Safety Guide for Next.js and Supabase with TypeScript

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!
Enter fullscreen mode Exit fullscreen mode

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 exist
Enter fullscreen mode Exit fullscreen mode

Generating Database Types

Generate TypeScript types from your database schema:

npx supabase gen types typescript --project-id your-project-id > types/database.types.ts
Enter fullscreen mode Exit fullscreen mode

Or from local database:

npx supabase gen types typescript --local > types/database.types.ts
Enter fullscreen mode Exit fullscreen mode

This 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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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!
  )
}
Enter fullscreen mode Exit fullscreen mode

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
        },
      },
    }
  )
}
Enter fullscreen mode Exit fullscreen mode

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 }[]
Enter fullscreen mode Exit fullscreen mode

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'][]
Enter fullscreen mode Exit fullscreen mode

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
// }[]
Enter fullscreen mode Exit fullscreen mode

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'
})
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode

Delete

const { error } = await supabase
  .from('posts')
  .delete()
  .eq('id', postId)
Enter fullscreen mode Exit fullscreen mode

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[]
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Runtime Validation with Zod

TypeScript provides compile-time safety. Zod provides runtime validation:

npm install zod
Enter fullscreen mode Exit fullscreen mode

Create 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>
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

Type-Safe Forms

Combine React Hook Form with Zod:

npm install react-hook-form @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode
// 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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;
$$;
Enter fullscreen mode Exit fullscreen mode

After regenerating types:

const { data } = await supabase
  .rpc('get_user_stats', { user_id: userId })

// data is typed based on function return type
Enter fullscreen mode Exit fullscreen mode

For 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 UserStats
Enter fullscreen mode Exit fullscreen mode

Type-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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

Generated types include enums:

// types/database.types.ts
export interface Database {
  public: {
    Enums: {
      post_status: 'draft' | 'published' | 'archived'
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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' // Error
Enter fullscreen mode Exit fullscreen mode

Automating 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Run after migrations:

npx supabase db reset
npm run types:generate
Enter fullscreen mode Exit fullscreen mode

Or 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
fi
Enter fullscreen mode Exit fullscreen mode

Common 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 checking
Enter fullscreen mode Exit fullscreen mode

Always 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('*')
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

4. Incorrect Type Assertions

// ❌ Unsafe assertion
const post = data as Post

// ✅ Validate first
const post = PostSchema.parse(data)
Enter fullscreen mode Exit fullscreen mode

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 | null
Enter fullscreen mode Exit fullscreen mode

Type-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 }
})
Enter fullscreen mode Exit fullscreen mode

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.

[INTERNAL LINK: nextjs-server-actions-supabase-complete-guide]
[INTERNAL LINK: nextjs-supabase-database-design-optimization]
[INTERNAL LINK: supabase-postgres-functions-triggers-guide]


Originally published at https://www.iloveblogs.blog

Top comments (0)