GraphQL — Rules and Conventions
1. Philosophy
- Schema-first — SDL is the contract. Generate types from schema, not vice versa.
- Colocate queries — Keep GraphQL documents next to components that use them.
- Fragments for reuse — Define fragments on types, spread in queries.
- Normalized cache — Apollo InMemoryCache with type policies = single source of truth.
- Type-safe end-to-end — Codegen from schema → TypeScript types → typed hooks.
2. Minimum Versions
| Technology | Minimum Version |
|---|---|
| Apollo Client | 3.10+ |
| Apollo Server | 4.10+ (if used) |
| GraphQL | 16.8+ |
| TypeScript | 5.4+ |
| Node.js | 22+ |
| React | 18.3+ |
3. Schema Fundamentals (SDL)
Core Types
type User {
id: ID!
email: String!
name: String
avatar: String
posts: [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
publishedAt: DateTime
}
type Query {
user(id: ID!): User
posts(filter: PostFilter, pagination: Pagination): PostConnection!
me: User
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
input PostFilter {
authorId: ID
search: String
published: Boolean
}
input Pagination {
first: Int
after: String
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
Rules
- ID is
ID!— notString - Non-null by default — use
!for required fields - Connections for lists — Relay-style pagination
- Scalars —
DateTimeas ISO string,JSONfor arbitrary data - Enums for fixed values —
enum Status { DRAFT PUBLISHED ARCHIVED }
4. Apollo Client Setup
Client Configuration
// lib/apollo/client.ts
import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";
import { onError } from "@apollo/client/link/error";
import { registerApolloClient } from "@apollo/experimental-nextjs-app-support/rsc";
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors)
graphQLErrors.forEach(({ message, locations, path }) =>
console.error(`[GraphQL error]: ${message}`),
);
if (networkError) console.error(`[Network error]: ${networkError}`);
});
const httpLink = new HttpLink({
uri: process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || "/api/graphql",
credentials: "include",
});
export const { getClient } = registerApolloClient(
() =>
new ApolloClient({
link: from([errorLink, httpLink]),
cache: new InMemoryCache({
typePolicies: typePolicies, // see §8
}),
defaultOptions: {
watchQuery: { fetchPolicy: "cache-and-network" },
query: { fetchPolicy: "cache-first" },
},
}),
);
Provider (Client Components)
// providers/ApolloProvider.tsx
"use client";
import { ApolloProvider } from "@apollo/client";
import { getClient } from "@/lib/apollo/client";
export function ApolloProvider({ children }: { children: React.ReactNode }) {
const client = getClient();
return <ApolloClient client={client}>{children}</ApolloClient>;
}
5. Queries
Query Document
# graphql/posts.graphql
query GetPosts($filter: PostFilter, $pagination: Pagination) {
posts(filter: $filter, pagination: $pagination) {
edges {
node {
id
title
excerpt
publishedAt
author {
id
name
avatar
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
Typed Hook (Codegen)
// hooks/usePosts.ts
import { useQuery } from "@apollo/client";
import { GetPostsDocument, GetPostsQueryVariables } from "@/graphql/generated";
export function usePosts(variables?: GetPostsQueryVariables) {
return useQuery<GetPostsQuery, GetPostsQueryVariables>(GetPostsDocument, {
variables,
notifyOnNetworkStatusChange: true,
});
}
Component Usage
// components/PostsList.tsx
export function PostsList() {
const { data, loading, error, fetchMore } = usePosts({
filter: { published: true },
});
if (loading && !data) return <Skeleton count={5} />;
if (error) return <Alert message="Failed to load posts" />;
return (
<div>
{data?.posts.edges.map(({ node }) => (
<PostCard key={node.id} post={node} />
))}
{data?.posts.pageInfo.hasNextPage && (
<button
=>
fetchMore({
variables: {
pagination: { after: data.posts.pageInfo.endCursor },
},
})
}
>
Load More
</button>
)}
</div>
);
}
6. Mutations
Mutation Document
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
createdAt
}
}
Typed Hook
// hooks/useCreatePost.ts
import { useMutation } from "@apollo/client";
import {
CreatePostDocument,
CreatePostMutationVariables,
} from "@/graphql/generated";
export function useCreatePost() {
const [createPost, { loading, error }] = useMutation<
CreatePostMutation,
CreatePostMutationVariables
>(CreatePostDocument, {
update(cache, { data }) {
// Update cache with new post (see §10)
},
onError: (error) => toast.error(error.message),
});
return { createPost, loading, error };
}
Component Usage Mutations
export function CreatePostForm() {
const [createPost, { loading }] = useCreatePost();
return (
<form
(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await createPost({
variables: { input: Object.fromEntries(formData) },
});
e.currentTarget.reset();
}}
>
<input name="title" required />
<textarea name="content" required />
<button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Post"}
</button>
</form>
);
}
7. Subscriptions
Subscription Document
subscription OnPostCreated {
postCreated {
id
title
author {
id
name
}
}
}
Usage
import { useSubscription } from '@apollo/client'
import { OnPostCreatedDocument } from '@/graphql/generated'
export function LivePosts() {
const { data, loading } = useSubscription(OnPostCreatedDocument)
// Merge with existing cache data
useEffect(() => {
if (data?.postCreated) {
queryClient.setQueryData(['posts'], (old) => ({
...old,
edges: [{ node: data.postCreated, cursor: '' }, ...old.edges]
})
}
}, [data])
return null // Or render live indicator
}
Rules Subscriptions
- WebSocket link required —
WebSocketLinkin Apollo Client - Merge with cache — manually update cache in subscription handler
- Auth over WS — connectionParams for token
8. Fragments
Define on Types
# graphql/fragments.graphql
fragment PostSummary on Post {
id
title
excerpt
publishedAt
author {
id
name
avatar
}
}
fragment PostDetail on Post {
...PostSummary
content
tags
publishedAt
author {
id
name
email
avatar
}
}
Spread in Queries
query GetPosts($pagination: Pagination) {
posts(pagination: $pagination) {
edges {
node {
...PostSummary
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
Typed Fragment Hooks
// hooks/usePostFragment.ts
import { PostSummaryFragmentDoc } from "@/graphql/generated";
export function PostCard({ post }: { post: PostSummaryFragment }) {
return (
<article>
<h2>{post.title}</h2>
</article>
);
}
9. Caching (InMemoryCache)
Type Policies
// lib/apollo/typePolicies.ts
import { TypePolicies } from "@apollo/client";
export const typePolicies: TypePolicies = {
Query: {
fields: {
posts: {
keyArgs: ["filter"],
merge(existing, incoming, { args }) {
if (!args?.pagination?.after) return incoming;
return {
...incoming,
edges: [...(existing?.edges || []), ...incoming.edges],
};
},
},
me: {
read() {
/* return from auth */
},
},
},
},
Post: {
keyFields: ["id"],
fields: {
author: { merge: false }, // Keep reference
},
},
User: {
keyFields: ["id"],
fields: {
posts: { merge: true },
},
},
};
Cache Operations
// Read
const data = cache.readQuery({ query: GetPostsDocument, variables });
// Write
cache.writeQuery({ query: GetPostsDocument, variables, data });
// Modify (partial update)
cache.modify({
id: cache.identify({ __typename: "Post", id: "1" }),
fields: {
title() {
return "New Title";
},
},
});
// Evict
cache.evict({ id: "Post:1" });
9. Field Policies
Pagination Merge
posts: {
keyArgs: ['filter'],
merge(existing, incoming, { args }) {
if (!args?.pagination?.after) return incoming
return {
...incoming,
edges: [...(existing?.edges || []), ...incoming.edges]
}
}
}
Reference Fields
Post: {
fields: {
author: {
merge: false, // Keep existing reference
read(existing, { toReference }) { return toReference(existing) }
}
}
}
Custom Reads
me: {
read(_, { readField }) {
return readField('id') ? { __typename: 'User', id: readField('id') } : null
}
}
10. Optimistic Updates
Mutation with Optimistic Response
const [createPost] = useMutation(CREATE_POST_MUTATION, {
optimisticResponse: (vars) => ({
createPost: {
__typename: 'Post',
id: `temp-${Date.now()}`,
title: vars.input.title,
content: vars.input.content,
publishedAt: new Date().toISOString(),
author: { __typename: 'User', id: currentUserId, name: 'Me' }
}
},
update(cache, { data }) {
// Update list cache
cache.modify({
fields: {
posts(existing, { toReference }) {
const newPostRef = cache.writeFragment({
data: data.createPost,
fragment: PostSummaryFragmentDoc
})
return [newPostRef, ...(existing || [])]
}
}
})
}
})
Rules Optimization Update
optimisticResponse— shape matches mutation return typeupdatefunction — manipulate cache directly- Rollback on error — Apollo handles automatically
- Type-safe — TypeScript validates optimistic response shape
11. Error Handling
Error Link
// lib/apollo/client.ts
import { onError } from "@apollo/client/link/error";
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
if (err.extensions?.code === "UNAUTHENTICATED") {
// Redirect to login
window.location.href = "/login";
}
logError(err.message, {
path: err.path,
operation: operation.operationName,
});
}
}
if (networkError) {
logError("Network error", { error: networkError });
}
});
Component Error Boundary
import { ApolloError } from "@apollo/client";
function ErrorBoundary({ children }) {
const [error, setError] = useState<ApolloError | null>(null);
return (
<ApolloErrorBoundary
{error ? (
<Alert severity="error" => setError(null)}>
{error.graphQLErrors?.[0]?.message || error.networkError?.message}
</Alert>
) : (
children
)}
</ApolloErrorBoundary>
);
}
12. Local State
Reactive Variables
// lib/apollo/localState.ts
import { makeVar } from "@apollo/client";
export const cartVar = makeVar<CartItem[]>([]);
export const themeVar = makeVar<"light" | "dark">("light");
// Initialize from localStorage
if (typeof window !== "undefined") {
cartVar(JSON.parse(localStorage.getItem("cart") || "[]"));
themeVar(localStorage.getItem("theme") === "dark" ? "dark" : "light");
}
// Persist
cartVar.onNextChange((val) =>
localStorage.setItem("cart", JSON.stringify(val)),
);
themeVar.onNextChange((val) => localStorage.setItem("theme", val));
Usage Local State
import { useReactiveVar } from "@apollo/client";
import { cartVar } from "@/lib/apollo/localState";
export function CartCount() {
const cart = useReactiveVar(cartVar);
return <span>{cart.length} items</span>;
}
function AddToCart({ product }) {
const add = () => cartVar((current) => [...current, { product, qty: 1 }]);
return <button to Cart</button>;
}
13. Codegen
Config
// graphql-codegen.config.ts
import { defineConfig } from "@graphql-codegen/cli";
export default defineConfig({
schema: "https://api.example.com/graphql",
documents: ["src/**/*.graphql", "src/**/*.tsx"],
generates: {
"src/graphql/generated.ts": {
plugins: [
"typescript",
"typescript-operations",
"typescript-react-apollo",
"typed-document-node",
],
config: {
withHooks: true,
withHOC: false,
withComponent: false,
scalars: { DateTime: "string", JSON: "Record<string, unknown>" },
},
},
},
});
Generated Types
// src/graphql/generated.ts (auto-generated)
export type GetPostsQuery = { posts: { edges: PostEdge[]; pageInfo: PageInfo } }
export type GetPostsQueryVariables = { filter?: PostFilter; pagination?: Pagination }
export const GetPostsDocument = { kind: 'Document', definitions: [...] }
14. Methodology
Before using ANY GraphQL pattern not documented in this skill:
- MCP Context7 (priority):
context7_resolve-library-id+context7_query-docsfor Apollo Client, GraphQL Code Generator. - Official docs: apollographql.com/docs, graphql.org — verify current APIs.
- Project config:
apollo-client.ts,codegen.ts,tsconfig.json— verify against actual setup. - HARD RULE: If not in this skill AND cannot be verified against 2 authoritative sources → DO NOT USE IT. Document as assumption or risk in report to orchestrator.
15. Prohibitions
- ❌ Do not write queries inline in components — use
.graphqlfiles + codegen - ❌ Do not skip fragments — reuse via fragment spreading
- ❌ Do not use
fetchPolicy: 'network-only'everywhere — default cache-first - ❌ Do not skip
keyFieldsin typePolicies — cache needs identity - ❌ Do not use
anyin typed hooks — codegen provides strict types - ❌ Do not mutate cache directly outside
updatefunctions - ❌ Do not skip error handling — GraphQL errors are not exceptions
- ❌ Do not use subscriptions without auth on WebSocket connection
- ❌ Do not store sensitive data in reactive variables — localStorage is readable
16. References
Note: For React patterns (hooks, components), see React Note: For TypeScript rules, see TypeScript Note: For JavaScript conventions, see JavaScript Note: For Next.js patterns (SSR), see Next.js Note: For Testing patterns, see Testing Note: For Security (rate limiting, auth), see Security Note: For Performance (caching, batching), see Performance Note: For Deployment, see Deploy
Last updated: 2026-08