GraphQL Integration with Next.js and Supabase Guide
Developer Guide

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.

2026-03-12
40 min read
GraphQL Integration with Next.js and Supabase Guide

GraphQL Integration with Next.js and Supabase Guide#

Integrating GraphQL with Next.js and Supabase provides type-safe APIs, eliminates over-fetching, and enables complex nested queries in a single request. This guide covers complete implementation from schema design to production deployment.

TL;DR#

Build a production-ready GraphQL API layer on top of Supabase using Next.js. You'll implement type-safe schemas, efficient resolvers, authentication integration, and real-time subscriptions while maintaining Supabase's RLS security model.

Prerequisites#

  • Next.js 14+ with App Router experience
  • Supabase fundamentals (database, auth, RLS)
  • Basic GraphQL concepts (queries, mutations, subscriptions)
  • TypeScript proficiency

Problem Statement#

While Supabase's auto-generated REST API is powerful, complex applications need more flexibility: type-safe queries, nested data fetching, custom business logic, and unified API endpoints. GraphQL solves this by providing a flexible query language while preserving Supabase's security and real-time features.

Why Use GraphQL with Supabase Instead of REST?#

GraphQL provides type safety, eliminates over-fetching, enables complex nested queries in a single request, and offers better developer experience with auto-completion. It's especially valuable for mobile apps and complex frontend requirements where bandwidth and request efficiency matter.

How Do You Handle Supabase RLS with GraphQL?#

Pass the user JWT token to your GraphQL resolvers and create Supabase clients with that token. RLS policies will automatically apply when you use the authenticated client. Use context to share the authenticated client across resolvers for consistency.

What's the Performance Impact of GraphQL vs REST with Supabase?#

GraphQL can be faster due to reduced over-fetching and fewer round trips. However, complex queries can be slower than optimized REST endpoints. Use DataLoader pattern to prevent N+1 queries and implement query complexity analysis to maintain performance.

Step-by-Step Walkthrough#

1. Project Setup and Dependencies#

Install the required GraphQL packages:

npm install graphql @apollo/server @apollo/client graphql-tag
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

2. GraphQL Schema Definition#

Create a type-safe schema that mirrors your Supabase database:

// lib/graphql/schema.ts
import { gql } from 'graphql-tag'

export const typeDefs = gql`
  scalar DateTime
  scalar UUID

  type User {
    id: UUID!
    email: String!
    name: String
    avatar_url: String
    created_at: DateTime!
    posts: [Post!]!
    comments: [Comment!]!
  }

  type Post {
    id: UUID!
    title: String!
    content: String!
    published: Boolean!
    author_id: UUID!
    author: User!
    comments: [Comment!]!
    tags: [Tag!]!
    created_at: DateTime!
    updated_at: DateTime!
  }

  type Comment {
    id: UUID!
    content: String!
    post_id: UUID!
    post: Post!
    author_id: UUID!
    author: User!
    created_at: DateTime!
  }

  type Tag {
    id: UUID!
    name: String!
    posts: [Post!]!
  }

  input CreatePostInput {
    title: String!
    content: String!
    published: Boolean = false
    tag_ids: [UUID!] = []
  }

  input UpdatePostInput {
    title: String
    content: String
    published: Boolean
  }

  input PostFilters {
    published: Boolean
    author_id: UUID
    tag_ids: [UUID!]
    search: String
  }

  type Query {
    me: User
    posts(filters: PostFilters, limit: Int = 10, offset: Int = 0): [Post!]!
    post(id: UUID!): Post
    tags: [Tag!]!
  }

  type Mutation {
    createPost(input: CreatePostInput!): Post!
    updatePost(id: UUID!, input: UpdatePostInput!): Post!
    deletePost(id: UUID!): Boolean!
    createComment(post_id: UUID!, content: String!): Comment!
  }

  type Subscription {
    postUpdated(id: UUID!): Post!
    newComment(post_id: UUID!): Comment!
  }
`

3. GraphQL Context and Authentication#

Set up context with authenticated Supabase client:

// lib/graphql/context.ts
import { createClient } from '@supabase/supabase-js'
import type { User } from '@supabase/supabase-js'

export interface GraphQLContext {
  supabase: ReturnType<typeof createClient>
  user: User | null
}

export async function createContext(
  authorization?: string
): Promise<GraphQLContext> {
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )

  let user: User | null = null

  if (authorization?.startsWith('Bearer ')) {
    const token = authorization.substring(7)
    
    try {
      const { data: { user: authUser }, error } = await supabase.auth.getUser(token)
      if (!error && authUser) {
        user = authUser
        // Set the session for RLS
        await supabase.auth.setSession({
          access_token: token,
          refresh_token: '' // Not needed for server-side operations
        })
      }
    } catch (error) {
      console.error('Auth error:', error)
    }
  }

  return { supabase, user }
}

4. GraphQL Resolvers Implementation#

Create efficient resolvers that leverage Supabase's capabilities:

// lib/graphql/resolvers.ts
import { GraphQLError } from 'graphql'
import type { Resolvers } from './generated/types'
import type { GraphQLContext } from './context'

export const resolvers: Resolvers<GraphQLContext> = {
  Query: {
    me: async (_, __, { user, supabase }) => {
      if (!user) return null

      const { data, error } = await supabase
        .from('users')
        .select('*')
        .eq('id', user.id)
        .single()

      if (error) throw new GraphQLError(error.message, { extensions: { code: 'DATABASE_ERROR' } })
      return data
    },

    posts: async (_, { filters, limit, offset }, { supabase }) => {
      let query = supabase
        .from('posts')
        .select(`
          *,
          author:users(*),
          comments(count),
          post_tags(tag:tags(*))
        `)

      // Apply filters
      if (filters?.published !== undefined) {
        query = query.eq('published', filters.published)
      }
      
      if (filters?.author_id) {
        query = query.eq('author_id', filters.author_id)
      }

      if (filters?.search) {
        query = query.or(`title.ilike.%${filters.search}%,content.ilike.%${filters.search}%`)
      }

      if (filters?.tag_ids?.length) {
        query = query.in('id', 
          supabase
            .from('post_tags')
            .select('post_id')
            .in('tag_id', filters.tag_ids)
        )
      }

      const { data, error } = await query
        .order('created_at', { ascending: false })
        .range(offset, offset + limit - 1)

      if (error) throw new GraphQLError(error.message, { extensions: { code: 'DATABASE_ERROR' } })
      return data
    },

    post: async (_, { id }, { supabase }) => {
      const { data, error } = await supabase
        .from('posts')
        .select(`
          *,
          author:users(*),
          comments(*),
          post_tags(tag:tags(*))
        `)
        .eq('id', id)
        .single()

      if (error) throw new GraphQLError(error.message, { extensions: { code: 'DATABASE_ERROR' } })
      return data
    },

    tags: async (_, __, { supabase }) => {
      const { data, error } = await supabase
        .from('tags')
        .select('*')
        .order('name')

      if (error) throw new GraphQLError(error.message, { extensions: { code: 'DATABASE_ERROR' } })
      return data
    }
  },

  Mutation: {
    createPost: async (_, { input }, { user, supabase }) => {
      if (!user) throw new Error('Authentication required')

      const { data: post, error } = await supabase
        .from('posts')
        .insert({
          title: input.title,
          content: input.content,
          published: input.published,
          author_id: user.id
        })
        .select(`
          *,
          author:users(*)
        `)
        .single()

      if (error) throw new Error(error.message)

      // Handle tag associations
      if (input.tag_ids?.length) {
        const tagAssociations = input.tag_ids.map(tag_id => ({
          post_id: post.id,
          tag_id
        }))

        await supabase
          .from('post_tags')
          .insert(tagAssociations)
      }

      return post
    },

    updatePost: async (_, { id, input }, { user, supabase }) => {
      if (!user) throw new Error('Authentication required')

      const { data, error } = await supabase
        .from('posts')
        .update(input)
        .eq('id', id)
        .eq('author_id', user.id) // Ensure user owns the post
        .select(`
          *,
          author:users(*)
        `)
        .single()

      if (error) throw new Error(error.message)
      return data
    },

    deletePost: async (_, { id }, { user, supabase }) => {
      if (!user) throw new Error('Authentication required')

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

      if (error) throw new Error(error.message)
      return true
    },

    createComment: async (_, { post_id, content }, { user, supabase }) => {
      if (!user) throw new Error('Authentication required')

      const { data, error } = await supabase
        .from('comments')
        .insert({
          post_id,
          content,
          author_id: user.id
        })
        .select(`
          *,
          author:users(*),
          post:posts(*)
        `)
        .single()

      if (error) throw new Error(error.message)
      return data
    }
  },

  // Field resolvers for nested data
  Post: {
    comments: async (parent, _, { supabase }) => {
      const { data, error } = await supabase
        .from('comments')
        .select(`
          *,
          author:users(*)
        `)
        .eq('post_id', parent.id)
        .order('created_at', { ascending: true })

      if (error) throw new Error(error.message)
      return data
    },

    tags: async (parent, _, { supabase }) => {
      const { data, error } = await supabase
        .from('post_tags')
        .select('tag:tags(*)')
        .eq('post_id', parent.id)

      if (error) throw new Error(error.message)
      return data.map(item => item.tag)
    }
  },

  User: {
    posts: async (parent, _, { supabase }) => {
      const { data, error } = await supabase
        .from('posts')
        .select('*')
        .eq('author_id', parent.id)
        .order('created_at', { ascending: false })

      if (error) throw new Error(error.message)
      return data
    }
  }
}

5. Apollo Server Setup#

Create the GraphQL endpoint using Apollo Server:

// app/api/graphql/route.ts
import { ApolloServer } from '@apollo/server'
import { startServerAndCreateNextHandler } from '@as-integrations/next'
import { typeDefs } from '@/lib/graphql/schema'
import { resolvers } from '@/lib/graphql/resolvers'
import { createContext } from '@/lib/graphql/context'

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV === 'development',
})

const handler = startServerAndCreateNextHandler(server, {
  context: async (req) => {
    const authorization = req.headers.get('authorization')
    return createContext(authorization)
  }
})

export { handler as GET, handler as POST }

6. Client-Side Apollo Setup#

Configure Apollo Client for the frontend:

// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { createClient } from '@/lib/supabase/client'

const httpLink = createHttpLink({
  uri: '/api/graphql',
})

const authLink = setContext(async (_, { headers }) => {
  const supabase = createClient()
  const { data: { session } } = await supabase.auth.getSession()

  return {
    headers: {
      ...headers,
      authorization: session?.access_token ? `Bearer ${session.access_token}` : '',
    }
  }
})

export const apolloClient = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache({
    typePolicies: {
      Post: {
        fields: {
          comments: {
            merge(existing = [], incoming) {
              return incoming
            }
          }
        }
      }
    }
  }),
  defaultOptions: {
    watchQuery: {
      errorPolicy: 'all'
    }
  }
})

7. Code Generation Setup#

Generate TypeScript types from your schema:

# codegen.yml
overwrite: true
schema: "lib/graphql/schema.ts"
generates:
  lib/graphql/generated/types.ts:
    plugins:
      - "typescript"
      - "typescript-resolvers"
    config:
      contextType: "../context#GraphQLContext"
      mappers:
        User: "@supabase/supabase-js#User"
      scalars:
        DateTime: string
        UUID: string
  lib/graphql/generated/client.ts:
    documents: "lib/graphql/queries/**/*.ts"
    plugins:
      - "typescript"
      - "typescript-operations"
      - "typescript-react-apollo"
    config:
      withHooks: true
      withComponent: false

8. Real-time Subscriptions#

Implement GraphQL subscriptions using Supabase realtime:

// lib/graphql/subscriptions.ts
import { PubSub } from 'graphql-subscriptions'
import { createClient } from '@supabase/supabase-js'

const pubsub = new PubSub()

// Initialize Supabase realtime listeners
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // Use service role for server-side
)

// Listen to post changes
supabase
  .channel('posts')
  .on('postgres_changes', 
    { event: 'UPDATE', schema: 'public', table: 'posts' },
    (payload) => {
      pubsub.publish(`POST_UPDATED_${payload.new.id}`, {
        postUpdated: payload.new
      })
    }
  )
  .subscribe()

// Listen to new comments
supabase
  .channel('comments')
  .on('postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'comments' },
    (payload) => {
      pubsub.publish(`NEW_COMMENT_${payload.new.post_id}`, {
        newComment: payload.new
      })
    }
  )
  .subscribe()

// Add subscription resolvers
export const subscriptionResolvers = {
  Subscription: {
    postUpdated: {
      subscribe: (_, { id }) => pubsub.asyncIterator(`POST_UPDATED_${id}`)
    },
    newComment: {
      subscribe: (_, { post_id }) => pubsub.asyncIterator(`NEW_COMMENT_${post_id}`)
    }
  }
}

9. Frontend Usage Examples#

Use generated hooks in your components:

// components/PostList.tsx
'use client'

import { usePostsQuery, useNewCommentSubscription } from '@/lib/graphql/generated/client'

export function PostList() {
  const { data, loading, error, refetch } = usePostsQuery({
    variables: {
      filters: { published: true },
      limit: 10
    }
  })

  // Subscribe to new comments for real-time updates
  useNewCommentSubscription({
    onSubscriptionData: ({ subscriptionData }) => {
      if (subscriptionData.data?.newComment) {
        refetch() // Refetch posts to update comment counts
      }
    }
  })

  if (loading) return <div>Loading posts...</div>
  if (error) return <div>Error: {error.message}</div>

  return (
    <div className="space-y-4">
      {data?.posts.map(post => (
        <article key={post.id} className="border rounded-lg p-4">
          <h2 className="text-xl font-bold">{post.title}</h2>
          <p className="text-gray-600">By {post.author.name}</p>
          <p className="mt-2">{post.content}</p>
          <div className="mt-2 text-sm text-gray-500">
            {post.comments.length} comments • {post.tags.map(tag => tag.name).join(', ')}
          </div>
        </article>
      ))}
    </div>
  )
}

Common Pitfalls#

1. N+1 Query Problem#

Problem: Resolvers making separate database calls for each item in a list. Solution: Use Supabase's nested select syntax or implement DataLoader pattern for batching.

2. Ignoring RLS in Resolvers#

Problem: Bypassing Supabase RLS by using service role key inappropriately. Solution: Always use user tokens in resolvers. Only use service role for system operations.

3. Over-complex GraphQL Queries#

Problem: Allowing unlimited query depth leading to performance issues. Solution: Implement query complexity analysis and depth limiting.

Production Considerations#

Performance Optimization#

  • Implement query complexity analysis to prevent expensive queries
  • Use DataLoader pattern for efficient batching
  • Cache frequently accessed data with appropriate TTL
  • Monitor resolver performance and optimize slow queries

Security Hardening#

  • Validate all input parameters in resolvers
  • Implement rate limiting per user/IP
  • Use query whitelisting in production
  • Audit GraphQL queries for sensitive data exposure

Monitoring and Observability#

  • Log all GraphQL operations with execution time
  • Monitor resolver performance metrics
  • Set up alerts for failed queries and slow operations
  • Track schema usage to identify unused fields

Further Reading#

  1. Advanced GraphQL Patterns - Federation, schema stitching, and microservices
  2. Performance Optimization - Caching strategies and query optimization
  3. Security Best Practices - Query validation and access control
  4. Real-time Architecture - Advanced subscription patterns and scaling
  5. Testing Strategies - Unit testing resolvers and integration testing

Frequently Asked Questions

|

Have more questions? Contact us