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
Originally published at https://www.iloveblogs.blog
Top comments (0)