# GRAPHQL Integration

> 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. Use when this capability is needed.

- Skill: `tomevault-io/graphql-integration` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tomevault-io/graphql-integration`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tomevault-io/graphql-integration/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: tomevault-io (https://skillmd.com/u/tomevault-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tomevault-io/graphql-integration

---


# GraphQL Integration Skill

## Overview
This skill covers GraphQL schema, code generation, and integration patterns used across all three applications in the Game Critique project.

## GraphQL Architecture

### Code-First vs Schema-First
- **API (NestJS)**: Code-first approach - schema auto-generated from TypeScript decorators
- **Web & Native**: Schema-first - consume generated schema from API

### Schema Location
```yaml
# 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`.

## API: Code-First Schema Generation

### Creating GraphQL Types
```typescript
// 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;
}
```

### Input Types and Arguments
```typescript
@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;
}
```

### Resolvers with Queries and Mutations
```typescript
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;
  }
}
```

### Custom Scalars
```typescript
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;
  }
}
```

### Field Resolvers
```typescript
@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);
  }
}
```

## Native App: Apollo Client Integration

### Code Generation Configuration
```typescript
// 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;
```

### Writing GraphQL Operations
```graphql
# 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
  }
}
```

### Using Generated Apollo Hooks
```typescript
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}
    />
  );
}
```

### Apollo Client Setup
```typescript
// 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>;
}
```

### Cache Management
```typescript
// 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
```

## Web App: React Query Integration

### Code Generation Configuration
```typescript
// 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;
```

### Custom Fetcher
```typescript
// 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;
}
```

### Fetcher with Auth
```typescript
// 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}`,
    });
  };
}
```

### Writing GraphQL Operations
```graphql
# 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)
}
```

### Using Generated React Query Hooks
```typescript
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>
  );
}
```

### Optimistic Updates
```typescript
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'] });
  },
});
```

## GraphQL Code Generation Workflow

### Step-by-Step Process

1. **Define operation in API**
```typescript
// 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);
}
```

2. **Start API to generate schema**
```bash
cd apps/api
yarn dev  # Schema auto-generated at src/schema.gql
```

3. **Create GraphQL operation file**
```graphql
# apps/native/modules/games/use_game/game.graphql
query GetGame($id: Int!) {
  game(id: $id) {
    hltbId
    title
    description
    platforms
    rating
  }
}
```

4. **Run code generation**
```bash
# Native app
cd apps/native
yarn generate-graph

# Web app
cd apps/web
yarn generate-codegen-dev
```

5. **Use generated hooks**
```typescript
import { useGetGameQuery } from './game.generated';

export function GameDetails({ id }) {
  const { data, loading } = useGetGameQuery({ variables: { id } });
  // ...
}
```

## Best Practices

### 1. Co-locate Operations with Components
```
modules/games/
  game_details/
    game_details.tsx
    use_game/
      game.graphql
      game.generated.ts
```

### 2. Use Fragments for Reusability
```graphql
fragment GameBasic on GameDTO {
  hltbId
  title
  coverUrl
}

query GetGame($id: Int!) {
  game(id: $id) {
    ...GameBasic
    description
    platforms
  }
}

query GetGames {
  games {
    ...GameBasic
  }
}
```

### 3. Proper Error Handling
```typescript
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} />;
}
```

### 4. Optimize with Field Selection
Only query what you need:
```graphql
# 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
  }
}
```

### 5. Use Variables for Dynamic Queries
```graphql
# Bad - hardcoded values
query GetGame {
  game(id: 123) {
    title
  }
}

# Good - use variables
query GetGame($id: Int!) {
  game(id: $id) {
    title
  }
}
```

### 6. Implement Proper Loading States
```typescript
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 />;
```

## Common Patterns

### Pagination
```graphql
query GetGames($take: Int!, $skip: Int!) {
  games(take: $take, skip: $skip) {
    games {
      id
      title
    }
    total
  }
}
```

### Infinite Scroll (Native)
```typescript
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],
        },
      };
    },
  });
};
```

### Search with Debouncing
```typescript
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 500);

const { data } = useSearchGamesQuery(
  { search: debouncedSearch },
  { enabled: debouncedSearch.length >= 3 }
);
```

## Debugging

### Enable GraphQL DevTools

**Native:**
```typescript
// Apollo DevTools plugin already configured
import { __DEV__ } from 'react-native';

const client = new ApolloClient({
  // ...
  connectToDevTools: __DEV__,
});
```

**Web:**
```typescript
// React Query DevTools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

<QueryClientProvider client={queryClient}>
  {children}
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
```

### Logging Queries
```typescript
// 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,
]);
```

## Code Generation Commands

```bash
# 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
```

## Common Issues & Solutions

### Issue: Generated types not found
**Solution:** Ensure code generation completed successfully and restart TypeScript server

### Issue: Schema changes not reflected
**Solution:** Restart API server to regenerate schema, then re-run codegen in clients

### Issue: Authentication errors
**Solution:** Check token is properly passed in headers for both Apollo Client and fetcher

### Issue: Cache not updating
**Solution:** Use `refetchQueries`, `invalidateQueries`, or manual cache updates

---
> Source: [Survikrowa/Game-Critique](https://github.com/Survikrowa/Game-Critique) — distributed by [TomeVault](https://tomevault.io).
<!-- tomevault:4.0:skill_md:2026-06-16 -->

