Developer Guide

Type Safety Guide for Next.js + Supabase in TypeScript

Type safety in Next.js with Supabase: database type generation, Zod validation, type-safe queries, and production TypeScript patterns.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
19 min read

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:

typescript
// ❌ 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:

typescript
// ✅ Full type safety
const { data, error } = await supabase
  .from('posts')
  .select('*')
 
if (error) throw error
 
// data is now Post[] - autocomplete works, typos caught
console.log(data[0].title) // ✓
console.log(data[0].titl) // Error: Property 'titl' does not exist

Generating Database Types#

Per the official Supabase guide on generating TypeScript types, Supabase uses database introspection to produce type-safe definitions you then supply to supabase-js. Generate TypeScript types from your database schema:

bash
npx supabase gen types typescript --project-id your-project-id > types/database.types.ts

Or from local database:

bash
npx supabase gen types typescript --local > types/database.types.ts

This creates:

typescript
// 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: "profiles"
            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:

typescript
// 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. The Supabase server-side client guide passes getAll and setAll: setAll is called whenever the library needs to write cookies, for example after a token refresh, so a client given only get cannot store a refreshed session:

typescript
// 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: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Called from a Server Component, which cannot set cookies.
            // Safe to ignore if a proxy refreshes user sessions.
          }
        },
      },
    }
  )
}

If upgrading @supabase/supabase-js suddenly throws Property 'auth' does not exist on SupabaseClient, it's a type-definitions mismatch, not a regression. The TypeScript + Node.js 20 install fix covers realigning the Supabase client, @types/node, and module/moduleResolution so compile types and runtime match.

Now all queries are type-safe:

typescript
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 }[] | null
// (null until you check error)

Type-Safe Queries#

Basic Queries#

Every query returns { data, error }, and data is typed T | null until you check error. The comments below show the type of data after an if (error) throw error check.

typescript
// 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#

The embedded profiles (...) select only resolves when a foreign key links the two tables, which the generated Relationships entry records (here posts_user_id_fkey pointing at profiles). Without it, supabase-js types the embed as a SelectQueryError ("could not find the relation between posts and profiles").

typescript
// 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
//   }
// }[]
// profiles is not nullable here because posts.user_id is NOT NULL

Insert#

typescript
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#

typescript
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#

typescript
const { error } = await supabase
  .from('posts')
  .delete()
  .eq('id', postId)

Helper Types#

Extract types for use in your application:

typescript
// 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'>
}
 
export type PostWithComments = Post & {
  comments: Comment[]
}

Use in components:

typescript
// 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 — "a TypeScript-first validation library" per its own docs — provides runtime validation:

bash
npm install zod

Create schemas matching your database types:

typescript
// 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:

typescript
// 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:

bash
npm install react-hook-form @hookform/resolvers
typescript
// components/PostForm.tsx
'use client'
 
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { PostInsertSchema, type PostInput } from '@/lib/validations/post'
import { createPost } from '@/actions/posts'
 
// published has a .default(), so the form's input type differs from the parsed output
type PostFormValues = z.input<typeof PostInsertSchema>
 
export function PostForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm<PostFormValues, unknown, PostInput>({
    resolver: zodResolver(PostInsertSchema)
  })
 
  async function onSubmit(data: PostInput) {
    const formData = new FormData()
    formData.append('title', data.title)
    formData.append('content', data.content)
    // The Server Action reads a checked box as 'on'
    if (data.published) formData.append('published', 'on')
 
    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:

sql
-- 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:

typescript
const { data } = await supabase
  .rpc('get_user_stats', { user_id: userId })
 
// data is typed based on function return type

For complex return types, define manually:

typescript
// 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

Type-Safe Realtime#

Type realtime subscriptions:

typescript
'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:

sql
CREATE TYPE post_status AS ENUM ('draft', 'published', 'archived');
 
ALTER TABLE posts
ADD COLUMN status post_status DEFAULT 'draft';

Generated types include enums:

typescript
// types/database.types.ts
export interface Database {
  public: {
    Enums: {
      post_status: 'draft' | 'published' | 'archived'
    }
  }
}

Use in your code:

typescript
import type { Database } from '@/types/database.types'
 
type PostStatus = Database['public']['Enums']['post_status']
 
const status: PostStatus = 'published' // ✓
const invalid: PostStatus = 'pending' // Error

Automating Type Generation#

Regenerate types after schema changes:

json
// 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:

bash
npx supabase db reset
npm run types:generate

Or use a git hook:

bash
# .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

Common Pitfalls#

1. Not Regenerating Types#

typescript
// ❌ Added column to database but didn't regenerate types
const { data } = await supabase
  .from('posts')
  .select('*')
 
console.log(data[0].new_column) // Error TS2339: Property 'new_column' does not exist

Always regenerate after schema changes.

2. Using any Types#

typescript
// ❌ 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#

typescript
// ❌ 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#

typescript
// ❌ Unsafe assertion
const post = data as Post
 
// ✅ Validate first
const post = PostSchema.parse(data)

Typing Query Results with QueryData#

Writing join types such as PostWithAuthor by hand drifts from the query that produces them. The Supabase type generation guide recommends QueryData from @supabase/supabase-js, which derives the result type from the query itself, nested joins included:

typescript
import type { QueryData } from '@supabase/supabase-js'
 
const postsWithAuthorsQuery = supabase.from('posts').select(`
  id,
  title,
  profiles (
    name,
    avatar_url
  )
`)
 
type PostsWithAuthors = QueryData<typeof postsWithAuthorsQuery>
 
const { data, error } = await postsWithAuthorsQuery
if (error) throw error
 
const postsWithAuthors: PostsWithAuthors = data

Change the select string and PostsWithAuthors changes with it. The same guide notes that the generated file also exports shorthands such as Tables<'posts'> and Enums<'post_status'> in place of the full Database['public']['Tables']['posts']['Row'] path.

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

More on This Topic#

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.