DEV Community

Atlas Whoff
Atlas Whoff

Posted on

GraphQL in Next.js with Pothos and Prisma: Type-Safe Schema-First API Development

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
Enter fullscreen mode Exit fullscreen mode
// 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({})
Enter fullscreen mode Exit fullscreen mode

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 } })
    },
  })
)
Enter fullscreen mode Exit fullscreen mode

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 },
      })
    },
  })
)
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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)