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 | 微服务网关 |
| Pothos | TypeScript-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-resolvers2.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 DateTime2.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
| 维度 | GraphQL | tRPC | Server Action |
|---|---|---|---|
| 类型安全 | CodeGen | 原生推导 | Zod 验证 |
| 学习曲线 | 高 | 中 | 低 |
| 对外 API | ✅ 最佳 | ❌ | ❌ |
| 多客户端 | ✅ | 仅 TS | 仅 Next.js |
| 灵活查询 | ✅ 客户端决定 | ❌ 固定 | ❌ 固定 |
| 文件大小 | ~50KB | ~10KB | 0KB |
| 生态 | 最丰富 | 增长中 | 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