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.
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#
- Advanced GraphQL Patterns - Federation, schema stitching, and microservices
- Performance Optimization - Caching strategies and query optimization
- Security Best Practices - Query validation and access control
- Real-time Architecture - Advanced subscription patterns and scaling
- Testing Strategies - Unit testing resolvers and integration testing
Frequently Asked Questions
Related Guides
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.
AI Integration for Next.js + Supabase Applications
Complete guide to integrating AI capabilities into Next.js and Supabase applications. Learn OpenAI integration, chat interfaces, vector search, RAG systems,...
Complete Guide to Building SaaS with Next.js and Supabase
Master full-stack SaaS development with Next.js 15 and Supabase. From database design to deployment, learn everything you need to build production-ready...