GraphQL gets a bad reputation for complexity. That reputation is earned — but only when it's used wrong. For the right problems (complex data graphs, multiple clients with different data needs, real-time subscriptions), GraphQL eliminates massive amounts of boilerplate. Here's the practical implementation.
When GraphQL vs REST
Use GraphQL when:
- Multiple clients (web, mobile, third-party) need different data shapes
- Your data is highly relational and you're solving N+1 problems
- You want real-time subscriptions alongside queries
- Your API surface is complex enough to benefit from introspection
Use REST when:
- Simple CRUD with predictable data shapes
- Small team, simple requirements
- You need HTTP-level caching (CDN) on responses
- tRPC already solves your problem (for Next.js stacks)
Schema-First with Pothos
Pothos is the best TypeScript-first GraphQL schema builder:
npm install @pothos/core @pothos/plugin-prisma graphql
// graphql/builder.ts
import SchemaBuilder from '@pothos/core'
import PrismaPlugin from '@pothos/plugin-prisma'
import type PrismaTypes from '@pothos/plugin-prisma/generated'
import { db } from '@/lib/db'
export const builder = new SchemaBuilder<{
PrismaTypes: PrismaTypes
Context: { userId: string | null }
}>({
plugins: [PrismaPlugin],
prisma: { client: db },
})
builder.queryType({})
builder.mutationType({})
Defining Types
// graphql/types/user.ts
import { builder } from '../builder'
builder.prismaObject('User', {
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
email: t.exposeString('email', {
// Only expose email to the user themselves
authScopes: { loggedIn: true },
}),
posts: t.relation('posts'),
postCount: t.relationCount('posts'),
}),
})
builder.queryField('me', (t) =>
t.prismaField({
type: 'User',
nullable: true,
resolve: (query, _root, _args, ctx) => {
if (!ctx.userId) return null
return db.user.findUnique({ ...query, where: { id: ctx.userId } })
},
})
)
Mutations
builder.mutationField('updateProfile', (t) =>
t.prismaField({
type: 'User',
args: {
name: t.arg.string({ required: true }),
bio: t.arg.string(),
},
resolve: async (query, _root, args, ctx) => {
if (!ctx.userId) throw new Error('Not authenticated')
return db.user.update({
...query,
where: { id: ctx.userId },
data: { name: args.name, bio: args.bio ?? undefined },
})
},
})
)
Next.js Route Handler
// app/api/graphql/route.ts
import { createYoga } from 'graphql-yoga'
import { schema } from '@/graphql/schema'
import { getServerSession } from 'next-auth'
const yoga = createYoga({
schema,
context: async () => {
const session = await getServerSession()
return { userId: session?.user?.id ?? null }
},
graphqlEndpoint: '/api/graphql',
})
export { yoga as GET, yoga as POST }
Client with urql
import { useQuery, gql } from 'urql'
const ME_QUERY = gql`
query Me {
me {
id
name
posts {
id
title
}
}
}
`
function Profile() {
const [{ data, fetching, error }] = useQuery({ query: ME_QUERY })
if (fetching) return <Spinner />
if (error) return <Error message={error.message} />
return <div>{data?.me?.name}</div>
}
Solving N+1 with DataLoader
import DataLoader from 'dataloader'
// Batch author lookups
const authorLoader = new DataLoader(async (userIds: readonly string[]) => {
const users = await db.user.findMany({ where: { id: { in: [...userIds] } } })
return userIds.map(id => users.find(u => u.id === id) ?? null)
})
// Add to context so all resolvers share the same loader
context: async () => ({ authorLoader, userId: session?.user?.id })
The AI SaaS Starter at whoffagents.com uses tRPC for type-safe APIs by default. If your project needs GraphQL, the architecture supports swapping in Pothos + graphql-yoga cleanly. $99 one-time.
Top comments (0)