GraphQL 全栈集成

在 tRPC 和 Server Action 主导的 Next.js 生态中,GraphQL 依然有它不可替代的位置:对外暴露公共 API、移动端和 Web 共享一套接口、聚合多个微服务的数据、以及团队已有 GraphQL 基础设施需要集成。本章覆盖在 Next.js 中搭建 GraphQL 服务端和客户端的完整方案。

1. GraphQL 在 Next.js 中的定位

1.1 什么时候用 GraphQL

✅ 适合 GraphQL:
- 对外公开 API(第三方开发者调用)
- 移动端 + Web 共用一套 API
- 聚合多个微服务(GraphQL Gateway)
- 客户端需要灵活查询(不同页面取不同字段)
- 已有 GraphQL 基础设施

❌ 不需要 GraphQL:
- 纯 Next.js 全栈项目 → Server Component 直查 DB
- 前端内部 API → tRPC 更简单
- 简单 CRUD → Server Action 足够

1.2 方案对比

服务端方案特点推荐场景
GraphQL Yoga轻量、支持 Edge/Node、Envelop 插件新项目首选
Apollo Server生态成熟、Apollo Federation微服务网关
PothosTypeScript-first Schema Builder类型安全偏好
客户端方案特点推荐场景
urql轻量(~7KB)、可扩展大多数项目
Apollo Client功能最全、本地状态管理复杂状态管理
graphql-request最小(~3KB)、无缓存简单请求 / SSR

2. GraphQL Yoga 集成

2.1 安装

pnpm add graphql graphql-yoga
pnpm add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

2.2 Schema 定义

# lib/graphql/schema.graphql
type Query {
  projects(page: Int = 1, limit: Int = 20): ProjectConnection!
  project(id: ID!): Project
  me: User!
}
 
type Mutation {
  createProject(input: CreateProjectInput!): Project!
  updateProject(id: ID!, input: UpdateProjectInput!): Project!
  deleteProject(id: ID!): Boolean!
}
 
type Project {
  id: ID!
  name: String!
  description: String
  members: [Member!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}
 
type User {
  id: ID!
  email: String!
  name: String
  role: String!
  projects: [Project!]!
}
 
type Member {
  id: ID!
  user: User!
  role: String!
  joinedAt: DateTime!
}
 
type ProjectConnection {
  items: [Project!]!
  totalCount: Int!
  hasMore: Boolean!
}
 
input CreateProjectInput {
  name: String!
  description: String
}
 
input UpdateProjectInput {
  name: String
  description: String
}
 
scalar DateTime

2.3 Resolver 实现

// lib/graphql/resolvers.ts
import { db } from '@/lib/db'
import { projects, users, members } from '@/lib/db/schema'
import { eq, count, desc } from 'drizzle-orm'
import type { Resolvers } from './generated/types'
 
export const resolvers: Resolvers = {
  Query: {
    projects: async (_parent, { page = 1, limit = 20 }, ctx) => {
      const offset = (page - 1) * limit
 
      const [items, [{ total }]] = await Promise.all([
        db
          .select()
          .from(projects)
          .where(eq(projects.tenantId, ctx.tenantId))
          .orderBy(desc(projects.updatedAt))
          .offset(offset)
          .limit(limit),
        db
          .select({ total: count() })
          .from(projects)
          .where(eq(projects.tenantId, ctx.tenantId)),
      ])
 
      return {
        items,
        totalCount: total,
        hasMore: offset + limit < total,
      }
    },
 
    project: async (_parent, { id }, ctx) => {
      const [project] = await db
        .select()
        .from(projects)
        .where(eq(projects.id, id))
      return project ?? null
    },
 
    me: async (_parent, _args, ctx) => {
      const [user] = await db
        .select()
        .from(users)
        .where(eq(users.id, ctx.userId))
      return user
    },
  },
 
  Mutation: {
    createProject: async (_parent, { input }, ctx) => {
      const [project] = await db
        .insert(projects)
        .values({ ...input, tenantId: ctx.tenantId })
        .returning()
      return project
    },
 
    updateProject: async (_parent, { id, input }, ctx) => {
      const [project] = await db
        .update(projects)
        .set({ ...input, updatedAt: new Date() })
        .where(eq(projects.id, id))
        .returning()
      return project
    },
 
    deleteProject: async (_parent, { id }, ctx) => {
      await db.delete(projects).where(eq(projects.id, id))
      return true
    },
  },
 
  // 字段级 Resolver(延迟加载关联数据)
  Project: {
    members: async (project) => {
      return db
        .select()
        .from(members)
        .where(eq(members.projectId, project.id))
    },
  },
 
  User: {
    projects: async (user, _args, ctx) => {
      return db
        .select()
        .from(projects)
        .where(eq(projects.tenantId, ctx.tenantId))
    },
  },
}

2.4 创建 Yoga 实例

// lib/graphql/yoga.ts
import { createYoga, createSchema } from 'graphql-yoga'
import { readFileSync } from 'fs'
import { join } from 'path'
import { resolvers } from './resolvers'
import { auth } from '@clerk/nextjs/server'
 
const typeDefs = readFileSync(
  join(process.cwd(), 'lib/graphql/schema.graphql'),
  'utf-8'
)
 
export const yoga = createYoga({
  schema: createSchema({ typeDefs, resolvers }),
  graphqlEndpoint: '/api/graphql',
  fetchAPI: { Response },
 
  // Context:每次请求注入认证信息
  context: async () => {
    const { userId, orgId } = await auth()
    if (!userId) throw new Error('Unauthorized')
    return { userId, tenantId: orgId }
  },
})

2.5 Route Handler

// app/api/graphql/route.ts
import { yoga } from '@/lib/graphql/yoga'
 
const { handleRequest } = yoga
 
export {
  handleRequest as GET,
  handleRequest as POST,
  handleRequest as OPTIONS,
}

现在访问 /api/graphql 即可使用 GraphQL Playground。

3. Schema 设计最佳实践

3.1 Relay 风格分页

# 推荐 Cursor-based 分页(Relay Connection 规范)
type Query {
  projects(
    first: Int
    after: String
    last: Int
    before: String
  ): ProjectConnection!
}
 
type ProjectConnection {
  edges: [ProjectEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}
 
type ProjectEdge {
  node: Project!
  cursor: String!
}
 
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

3.2 错误处理

# Union 类型处理业务错误
type Mutation {
  createProject(input: CreateProjectInput!): CreateProjectResult!
}
 
union CreateProjectResult = Project | ValidationError | PermissionError
 
type ValidationError {
  field: String!
  message: String!
}
 
type PermissionError {
  message: String!
}
// Resolver
createProject: async (_parent, { input }, ctx) => {
  if (!ctx.tenantId) {
    return { __typename: 'PermissionError', message: 'No tenant selected' }
  }
  if (input.name.length < 1) {
    return { __typename: 'ValidationError', field: 'name', message: 'Name is required' }
  }
  const [project] = await db.insert(projects).values({ ...input, tenantId: ctx.tenantId }).returning()
  return { __typename: 'Project', ...project }
}

3.3 DataLoader 解决 N+1

// lib/graphql/loaders.ts
import DataLoader from 'dataloader'
 
export function createLoaders() {
  return {
    userLoader: new DataLoader(async (userIds: readonly string[]) => {
      const users = await db
        .select()
        .from(usersTable)
        .where(inArray(usersTable.id, [...userIds]))
      
      // 保持顺序
      const userMap = new Map(users.map(u => [u.id, u]))
      return userIds.map(id => userMap.get(id) ?? null)
    }),
 
    projectMembersLoader: new DataLoader(async (projectIds: readonly string[]) => {
      const allMembers = await db
        .select()
        .from(membersTable)
        .where(inArray(membersTable.projectId, [...projectIds]))
      
      return projectIds.map(pid =>
        allMembers.filter(m => m.projectId === pid)
      )
    }),
  }
}
// yoga.ts — 每次请求创建新的 loader
context: async () => {
  const { userId, orgId } = await auth()
  return {
    userId,
    tenantId: orgId,
    loaders: createLoaders(),  // 每个请求独立的 DataLoader
  }
}
 
// Resolver 中使用
Project: {
  members: (project, _args, ctx) => ctx.loaders.projectMembersLoader.load(project.id),
}

4. 客户端集成

4.1 Server Component 中查询(graphql-request)

// lib/graphql/client.ts
import { GraphQLClient } from 'graphql-request'
 
export const graphqlClient = new GraphQLClient(
  process.env.NEXT_PUBLIC_APP_URL + '/api/graphql'
)
// app/(dashboard)/projects/page.tsx
import { graphqlClient } from '@/lib/graphql/client'
import { gql } from 'graphql-request'
 
const PROJECTS_QUERY = gql`
  query Projects($page: Int!, $limit: Int!) {
    projects(page: $page, limit: $limit) {
      items {
        id
        name
        description
        updatedAt
      }
      totalCount
      hasMore
    }
  }
`
 
export default async function ProjectsPage() {
  const data = await graphqlClient.request(PROJECTS_QUERY, {
    page: 1,
    limit: 20,
  })
 
  return <ProjectList projects={data.projects.items} />
}

4.2 Client Component 中查询(urql)

pnpm add urql @urql/next graphql
// lib/graphql/urql-provider.tsx
'use client'
import { UrqlProvider, ssrExchange, cacheExchange, fetchExchange, createClient } from '@urql/next'
import { useMemo } from 'react'
 
export function GraphQLProvider({ children }: { children: React.ReactNode }) {
  const [client, ssr] = useMemo(() => {
    const ssr = ssrExchange({ isClient: typeof window !== 'undefined' })
    const client = createClient({
      url: '/api/graphql',
      exchanges: [cacheExchange, ssr, fetchExchange],
      suspense: true,
    })
    return [client, ssr]
  }, [])
 
  return (
    <UrqlProvider client={client} ssr={ssr}>
      {children}
    </UrqlProvider>
  )
}
// app/layout.tsx
import { GraphQLProvider } from '@/lib/graphql/urql-provider'
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <GraphQLProvider>{children}</GraphQLProvider>
      </body>
    </html>
  )
}
// components/project-search.tsx
'use client'
import { useQuery, gql } from 'urql'
 
const SEARCH_QUERY = gql`
  query SearchProjects($query: String!) {
    searchProjects(query: $query) {
      id
      name
      description
    }
  }
`
 
export function ProjectSearch() {
  const [query, setQuery] = useState('')
  const [result] = useQuery({
    query: SEARCH_QUERY,
    variables: { query },
    pause: query.length < 2,  // 输入 2 个字符才开始搜索
  })
 
  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {result.fetching && <Spinner />}
      {result.data?.searchProjects.map(p => (
        <div key={p.id}>{p.name}</div>
      ))}
    </div>
  )
}

5. Code Generator

5.1 配置

# codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli'
 
const config: CodegenConfig = {
  schema: './lib/graphql/schema.graphql',
  documents: ['app/**/*.tsx', 'components/**/*.tsx', 'lib/**/*.ts'],
  generates: {
    // 服务端类型(Resolver 类型)
    './lib/graphql/generated/types.ts': {
      plugins: [
        'typescript',
        'typescript-resolvers',
      ],
      config: {
        contextType: '../yoga#YogaContext',
        mappers: {
          Project: '../../db/schema#Project',
          User: '../../db/schema#User',
        },
      },
    },
    // 客户端类型(Query/Mutation 类型)
    './lib/graphql/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
      ],
    },
  },
}
 
export default config
// package.json
{
  "scripts": {
    "codegen": "graphql-codegen",
    "codegen:watch": "graphql-codegen --watch"
  }
}

5.2 使用生成的类型

// 运行 pnpm codegen 后自动生成类型
import type { ProjectsQuery, ProjectsQueryVariables } from '@/lib/graphql/generated/graphql'
 
// 查询自动获得类型提示
const data = await graphqlClient.request<ProjectsQuery, ProjectsQueryVariables>(
  PROJECTS_QUERY,
  { page: 1, limit: 20 }
)
 
data.projects.items[0].name  // ← 有类型提示

6. 高级模式

6.1 Subscription(实时数据)

// lib/graphql/yoga.ts
import { createPubSub } from 'graphql-yoga'
 
const pubSub = createPubSub<{
  'project:created': [{ projectCreated: Project }]
  'project:updated': [{ projectUpdated: Project }]
}>()
 
// Schema
const typeDefs = `
  type Subscription {
    projectCreated: Project!
    projectUpdated(id: ID!): Project!
  }
`
 
// Resolver
Subscription: {
  projectCreated: {
    subscribe: () => pubSub.subscribe('project:created'),
  },
  projectUpdated: {
    subscribe: (_parent, { id }) =>
      pubSub.subscribe('project:updated', {
        filter: (payload) => payload.projectUpdated.id === id,
      }),
  },
}
 
// 在 Mutation 中发布事件
createProject: async (_parent, { input }, ctx) => {
  const [project] = await db.insert(projects).values(input).returning()
  pubSub.publish('project:created', { projectCreated: project })
  return project
}

6.2 GraphQL Gateway(Apollo Federation)

当有多个 GraphQL 微服务时:

用户服务 → GraphQL(User Schema)
项目服务 → GraphQL(Project Schema)
计费服务 → GraphQL(Billing Schema)

Apollo Gateway / GraphQL Mesh 合并为一个统一的 GraphQL 端点
// 使用 GraphQL Mesh 合并多个服务
// mesh.config.ts
import { defineConfig } from '@graphql-mesh/compose-cli'
 
export const composeConfig = defineConfig({
  subgraphs: [
    { sourceHandler: { graphql: { endpoint: 'http://users:4001/graphql' } } },
    { sourceHandler: { graphql: { endpoint: 'http://projects:4002/graphql' } } },
    { sourceHandler: { graphql: { endpoint: 'http://billing:4003/graphql' } } },
  ],
})

7. GraphQL vs tRPC vs Server Action

维度GraphQLtRPCServer Action
类型安全CodeGen原生推导Zod 验证
学习曲线
对外 API✅ 最佳
多客户端仅 TS仅 Next.js
灵活查询✅ 客户端决定❌ 固定❌ 固定
文件大小~50KB~10KB0KB
生态最丰富增长中Next.js 原生
RSC 集成需配置原生原生

选择建议

  • 纯 Next.js 内部 → Server Action + tRPC
  • 需要对外 API → GraphQL
  • 已有 GraphQL 基础设施 → 继续用 GraphQL
  • 多客户端(Web + Mobile) → GraphQL

本章小结

  • GraphQL Yoga:轻量级 GraphQL 服务端,通过 Route Handler 集成到 Next.js
  • Schema 设计:Relay 分页、Union 错误处理、DataLoader 解决 N+1
  • 客户端:Server Component 用 graphql-request(零依赖),Client Component 用 urql(轻量 + Suspense)
  • Code Generator:自动生成服务端 Resolver 类型和客户端 Query 类型,保证端到端类型安全
  • 高级模式:Subscription 实时推送、Apollo Federation / GraphQL Mesh 微服务网关
  • 定位:GraphQL 适合对外 API 和多客户端场景,纯 Next.js 项目优先 Server Action + tRPC