GraphQL code generation and integration patterns across API, web, and native apps. Use this skill when setting up GraphQL operations, configuring code generation, or integrating GraphQL queries and mutations.
This skill covers GraphQL schema, code generation, and integration patterns used across all three applications in the Game Critique project.
# graphql.config.yml (root)
schema: "apps/api/src/schema.gql"
The schema is automatically generated by NestJS on startup and stored at apps/api/src/schema.gql.
// DTOs serve as GraphQL types
import { ObjectType, Field, Int, ArgsType, InputType } from '@nestjs/graphql';
@ObjectType()
export class GameDTO {
@Field(() => Int)
hltbId: number;
@Field()
title: string;
@Field({ nullable: true })
description?: string;
@Field(() => [String])
platforms: string[];
@Field(() => Float, { nullable: true })
rating?: number;
}
@ObjectType()
export class PaginatedGamesDTO {
@Field(() => [GameDTO])
games: GameDTO[];
@Field(() => Int)
total: number;
@Field(() => Int)
page: number;
}
@InputType()
export class CreateGameInput {
@Field()
title: string;
@Field({ nullable: true })
description?: string;
@Field(() => [Int])
platformIds: number[];
}
@ArgsType()
export class GetGamesArgs {
@Field({ nullable: true })
search?: string;
@Field(() => Int, { defaultValue: 10 })
take: number;
@Field(() => Int, { defaultValue: 0 })
skip: number;
}
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
@Resolver(() => GameDTO)
export class GamesResolver {
constructor(private readonly gamesService: GamesService) {}
@Query(() => GameDTO, { name: 'game', nullable: true })
async getGame(@Args('id', { type: () => Int }) id: number) {
return this.gamesService.findById(id);
}
@Query(() => PaginatedGamesDTO, { name: 'games' })
async getGames(@Args() args: GetGamesArgs) {
return this.gamesService.findAll(args);
}
@Mutation(() => GameDTO, { name: 'createGame' })
async createGame(@Args('input') input: CreateGameInput) {
return this.gamesService.create(input);
}
@Mutation(() => Boolean, { name: 'deleteGame' })
async deleteGame(@Args('id', { type: () => Int }) id: number) {
await this.gamesService.delete(id);
return true;
}
}
import { Scalar, CustomScalar } from '@nestjs/graphql';
import { Kind, ValueNode } from 'graphql';
@Scalar('DateTime')
export class DateTimeScalar implements CustomScalar<string, Date> {
description = 'DateTime custom scalar type';
parseValue(value: string): Date {
return new Date(value);
}
serialize(value: Date): string {
return value.toISOString();
}
parseLiteral(ast: ValueNode): Date {
if (ast.kind === Kind.STRING) {
return new Date(ast.value);
}
return null;
}
}
@Resolver(() => UserDTO)
export class UserResolver {
@ResolveField(() => [GameDTO])
async games(@Parent() user: UserDTO) {
return this.gamesService.findByUserId(user.id);
}
@ResolveField(() => Int)
async gameCount(@Parent() user: UserDTO) {
return this.gamesService.countByUserId(user.id);
}
}
// apps/native/codegen.ts
import { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: process.env.EXPO_PUBLIC_GRAPHQL_ENDPOINT || "http://localhost:3000/graphql",
documents: ["modules/**/*.graphql"],
generates: {
"__generated__/types.ts": {
plugins: ["typescript"]
},
"./": {
preset: "near-operation-file",
presetConfig: {
extension: ".generated.ts",
baseTypesPath: "__generated__/types.ts",
},
plugins: ["typescript-operations", "typescript-react-apollo"],
config: {
withHooks: true,
},
},
},
};
export default config;
# modules/games_status/use_game_status/game_status.graphql
query GetGameStatus($gameId: Int!) {
gameStatus(gameId: $gameId) {
id
status
rating
hoursPlayed
completedAt
game {
hltbId
title
coverUrl
}
}
}
mutation UpsertGameStatus($input: UpsertGameStatusArgsDTO!) {
upsertGameStatus(upsertGameStatusArgs: $input) {
message
}
}
import {
useGetGameStatusQuery,
useUpsertGameStatusMutation,
} from './game_status.generated';
export function GameStatusScreen({ gameId }) {
// Query with loading and error states
const { data, loading, error, refetch } = useGetGameStatusQuery({
variables: { gameId },
fetchPolicy: 'cache-and-network',
});
// Mutation with optimistic updates
const [upsertStatus, { loading: saving }] = useUpsertGameStatusMutation({
onCompleted: () => {
toast.show('Status updated!');
refetch();
},
onError: (error) => {
toast.show(`Error: ${error.message}`);
},
});
const handleSubmit = (values) => {
upsertStatus({
variables: {
input: {
gameId,
status: values.status,
rating: values.rating,
hoursPlayed: values.hoursPlayed,
},
},
optimisticResponse: {
upsertGameStatus: {
__typename: 'UpsertGameStatusResponse',
message: 'Updating...',
},
},
});
};
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return (
<GameStatusForm
initialData={data?.gameStatus}
onSubmit={handleSubmit}
isSubmitting={saving}
/>
);
}
// modules/graphql/apollo_provider.tsx
import { ApolloClient, InMemoryCache, createHttpLink, ApolloProvider } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import * as SecureStore from 'expo-secure-store';
const httpLink = createHttpLink({
uri: process.env.EXPO_PUBLIC_GRAPHQL_ENDPOINT,
});
const authLink = setContext(async (_, { headers }) => {
const token = await SecureStore.getItemAsync('authToken');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
games: {
keyArgs: ['search'],
merge(existing, incoming, { args }) {
if (args?.skip === 0) return incoming;
return [...(existing || []), ...incoming];
},
},
},
},
},
}),
});
export function ApolloProvider({ children }) {
return <ApolloProvider client={client}>{children}</ApolloProvider>;
}
// Manually update cache after mutation
const [deleteGame] = useDeleteGameMutation({
update(cache, { data }) {
cache.modify({
fields: {
games(existingGames, { readField }) {
return existingGames.filter(
(gameRef) => readField('id', gameRef) !== data.deleteGame.id
);
},
},
});
},
});
// Refetch queries
const [createGame] = useCreateGameMutation({
refetchQueries: ['GetGames', 'GetUserStats'],
awaitRefetchQueries: true,
});
// Invalidate cache
const client = useApolloClient();
await client.resetStore(); // Clear entire cache
await client.refetchQueries({ include: ['GetGames'] }); // Refetch specific queries
// apps/web/codegen-dev.ts
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "http://localhost:3001/graphql",
documents: "src/**/*.graphql",
ignoreNoDocuments: true,
generates: {
"src/types.ts": {
plugins: ["typescript"],
},
"src/": {
preset: "near-operation-file",
presetConfig: {
extension: ".generated.ts",
baseTypesPath: "types.ts",
},
plugins: ["typescript-operations", "typescript-react-query"],
config: {
withHooks: true,
fetcher: "@/codegen/fetcher#fetchData",
reactQueryVersion: 5,
},
},
},
};
export default config;
// src/codegen/fetcher.ts
export async function fetchData<TData, TVariables>(
query: string,
variables?: TVariables,
options?: RequestInit['headers']
): Promise<TData> {
const response = await fetch('http://localhost:3001/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options,
},
body: JSON.stringify({
query,
variables,
}),
});
const json = await response.json();
if (json.errors) {
const { message } = json.errors[0];
throw new Error(message);
}
return json.data;
}
// Enhanced fetcher with Auth0 token
import { useAuth0 } from '@auth0/auth0-react';
export function useGraphQLFetcher() {
const { getAccessTokenSilently } = useAuth0();
return async <TData, TVariables>(
query: string,
variables?: TVariables
): Promise<TData> => {
const token = await getAccessTokenSilently();
return fetchData(query, variables, {
authorization: `Bearer ${token}`,
});
};
}
# features/admin/users/use_users/users.graphql
query GetUsers($search: String, $take: Int!, $skip: Int!) {
users(search: $search, take: $take, skip: $skip) {
users {
id
oauthId
profile {
name
email
avatarUrl
}
role {
role
}
}
total
}
}
mutation DeleteUser($userId: Int!) {
deleteUser(userId: $userId)
}
import { useGetUsersQuery, useDeleteUserMutation } from './users.generated';
import { useQueryClient } from '@tanstack/react-query';
export function UsersTable() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
// Query with pagination
const { data, isLoading, error } = useGetUsersQuery({
search,
take: 10,
skip: (page - 1) * 10,
}, {
staleTime: 5 * 60 * 1000, // 5 minutes
keepPreviousData: true,
});
// Mutation with cache invalidation
const queryClient = useQueryClient();
const { mutate: deleteUser, isPending } = useDeleteUserMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['GetUsers'] });
toast.success('User deleted');
},
onError: (error) => {
toast.error(error.message);
},
});
if (isLoading) return <TableSkeleton />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
/>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{data?.users.users.map((user) => (
<tr key={user.id}>
<td>{user.profile?.name}</td>
<td>{user.profile?.email}</td>
<td>{user.role?.role}</td>
<td>
<button
onClick={() => deleteUser({ userId: user.id })}
disabled={isPending}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={page}
totalPages={Math.ceil(data.users.total / 10)}
onPageChange={setPage}
/>
</div>
);
}
const { mutate } = useUpdateUserRoleMutation({
onMutate: async (variables) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['GetUsers'] });
// Snapshot previous value
const previousUsers = queryClient.getQueryData(['GetUsers']);
// Optimistically update cache
queryClient.setQueryData(['GetUsers'], (old) => {
return {
...old,
users: old.users.map((user) =>
user.id === variables.userId
? { ...user, role: { role: variables.role } }
: user
),
};
});
return { previousUsers };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(['GetUsers'], context.previousUsers);
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['GetUsers'] });
},
});
// apps/api/src/modules/games/games.resolver.ts
@Query(() => GameDTO, { name: 'game' })
async getGame(@Args('id', { type: () => Int }) id: number) {
return this.gamesService.findById(id);
}
cd apps/api
yarn dev # Schema auto-generated at src/schema.gql
# apps/native/modules/games/use_game/game.graphql
query GetGame($id: Int!) {
game(id: $id) {
hltbId
title
description
platforms
rating
}
}
# Native app
cd apps/native
yarn generate-graph
# Web app
cd apps/web
yarn generate-codegen-dev
import { useGetGameQuery } from './game.generated';
export function GameDetails({ id }) {
const { data, loading } = useGetGameQuery({ variables: { id } });
// ...
}
modules/games/
game_details/
game_details.tsx
use_game/
game.graphql
game.generated.ts
fragment GameBasic on GameDTO {
hltbId
title
coverUrl
}
query GetGame($id: Int!) {
game(id: $id) {
...GameBasic
description
platforms
}
}
query GetGames {
games {
...GameBasic
}
}
const { data, error } = useGetGameQuery({ variables: { id } });
if (error) {
if (error.message.includes('NOT_FOUND')) {
return <NotFound />;
}
if (error.message.includes('UNAUTHORIZED')) {
return <Unauthorized />;
}
return <ErrorMessage error={error} />;
}
Only query what you need:
# Bad - fetching unnecessary data
query GetGames {
games {
hltbId
title
description
platforms
genres
completionTime
# ... everything
}
}
# Good - only what's displayed
query GetGamesForList {
games {
hltbId
title
coverUrl
rating
}
}
# Bad - hardcoded values
query GetGame {
game(id: 123) {
title
}
}
# Good - use variables
query GetGame($id: Int!) {
game(id: $id) {
title
}
}
const { data, loading, error, refetch, networkStatus } = useGetGameQuery({
variables: { id },
notifyOnNetworkStatusChange: true,
});
const isRefetching = networkStatus === NetworkStatus.refetch;
if (loading && !isRefetching) return <Skeleton />;
if (error) return <Error />;
if (isRefetching) return <RefreshingIndicator />;
query GetGames($take: Int!, $skip: Int!) {
games(take: $take, skip: $skip) {
games {
id
title
}
total
}
}
const { data, fetchMore, loading } = useGetGamesQuery({
variables: { take: 20, skip: 0 },
});
const loadMore = () => {
fetchMore({
variables: { skip: data.games.games.length },
updateQuery: (prev, { fetchMoreResult }) => {
return {
games: {
...fetchMoreResult.games,
games: [...prev.games.games, ...fetchMoreResult.games.games],
},
};
},
});
};
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 500);
const { data } = useSearchGamesQuery(
{ search: debouncedSearch },
{ enabled: debouncedSearch.length >= 3 }
);
Native:
// Apollo DevTools plugin already configured
import { __DEV__ } from 'react-native';
const client = new ApolloClient({
// ...
connectToDevTools: __DEV__,
});
Web:
// React Query DevTools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
// API - enable playground
GraphQLModule.forRoot({
playground: true,
debug: true,
});
// Native - log all operations
const link = ApolloLink.from([
new ApolloLink((operation, forward) => {
console.log('GraphQL Operation:', operation.operationName);
return forward(operation);
}),
httpLink,
]);
# API - Schema auto-generates on start
cd apps/api
yarn dev
# Native - Generate from remote schema
cd apps/native
yarn generate-graph # Local dev
yarn generate-graph-prod # Production
# Web - Generate from remote schema
cd apps/web
yarn generate-codegen-dev
Solution: Ensure code generation completed successfully and restart TypeScript server
Solution: Restart API server to regenerate schema, then re-run codegen in clients
Solution: Check token is properly passed in headers for both Apollo Client and fetcher
Solution: Use refetchQueries, invalidateQueries, or manual cache updates
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer