Skip to main content
The 5Stack GraphQL API provides type-safe data access with real-time subscriptions, powered by Hasura GraphQL Engine and Apollo Client.

Architecture Overview

Hasura Engine

GraphQL API over PostgreSQL with real-time subscriptions

Apollo Client

GraphQL client with caching, subscriptions, and error handling

GraphQL Zeus

Type-safe query builder and code generator

GraphQL Zeus

Zeus generates TypeScript types and a type-safe query builder from the GraphQL schema.

Code Generation

Types are generated from the Hasura schema:
# package.json script
zeus https://$NUXT_PUBLIC_API_DOMAIN/v1/graphql ./generated --ts --td \
  --header=x-hasura-admin-secret:$HASURA_GRAPHQL_ADMIN_SECRET
The --td flag generates typed document nodes for use with Apollo Client.

Type-Safe Selectors

Zeus provides a Selector API for building reusable field selections:
// graphql/playerFields.ts
import { Selector } from "@/generated/zeus";

export const playerFields = Selector("players")({
  steam_id: true,
  name: true,
  avatar_url: true,
  country: true,
  elo: true,
  role: true,
  status: true,
});
// graphql/meGraphql.ts
import { Selector } from "@/generated/zeus";
import { playerFields } from "./playerFields";

export const meFields = Selector("players")({
  ...playerFields,          // Extend base fields
  name_registered: true,
  role: true,
  profile_url: true,
  matchmaking_cooldown: true,
  current_lobby_id: true,
  language: true,
  country: true,
  teams: [
    {},
    {
      id: true,
      name: true,
      short_name: true,
      role: true,
    },
  ],
});

Query Generation

Helper functions wrap Zeus queries with graphql-tag:
// graphql/graphqlGen.ts
import gql from "graphql-tag";
import { Zeus } from "@/generated/zeus";
import type { ValueTypes, OperationOptions, ScalarDefinition } from "@/generated/zeus";

export function generateQuery<Z extends ValueTypes[O], O extends "query_root">(
  query: Z | ValueTypes[O],
  operationOptions?: OperationOptions,
  scalars?: ScalarDefinition,
) {
  return gql(Zeus("query", query, { operationOptions, scalars }));
}

export function generateMutation<Z extends ValueTypes[O], O extends "mutation_root">(
  mutation: Z | ValueTypes[O],
  operationOptions?: OperationOptions,
  scalars?: ScalarDefinition,
) {
  return gql(Zeus("mutation", mutation, { operationOptions, scalars }));
}

export function generateSubscription<Z extends ValueTypes[O], O extends "query_root">(
  subscription: Z | ValueTypes[O],
  operationOptions?: OperationOptions,
  scalars?: ScalarDefinition,
) {
  return gql(Zeus("subscription", subscription, { operationOptions, scalars }));
}

Apollo Client Configuration

The Apollo Client is configured in a Nuxt plugin with support for both HTTP and WebSocket transports.

Plugin Setup

// plugins/apollo.client.ts
import { onError } from "@apollo/client/link/error";
import { RetryLink } from "@apollo/client/link/retry";
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { createClient } from "graphql-ws";
import { createHttpLink, from, split } from "@apollo/client/core";

export default defineNuxtPlugin((nuxtApp) => {
  const $apollo = nuxtApp.$apollo;
  const config = useRuntimeConfig();

  // Error handling link
  const errorLink = onError((error) => {
    nuxtApp.callHook('apollo:error', error);
  });

  // Retry logic for failed requests
  const retryLink = new RetryLink({
    delay: {
      initial: 300,
      max: 60000,
      jitter: true,
    },
    attempts: (count, operation, e) => {
      if (e?.response?.status === 401) return false;
      return count < 30;
    },
  });

  // HTTP link for queries and mutations
  const httpLink = createHttpLink({
    credentials: 'include',
    uri: `https://${config.public.apiDomain}/v1/graphql`,
  });

  // WebSocket link for subscriptions
  const wsClient = createClient({
    url: `wss://${config.public.apiDomain}/v1/graphql`,
    connectionParams: {
      credentials: 'include',
    },
  });

  nuxtApp.provide('wsClient', wsClient);
  const wsLink = new GraphQLWsLink(wsClient);

  // Split traffic between HTTP and WS based on operation
  const splitLink = split(
    ({ query }) => {
      const definition = getMainDefinition(query);
      return (
        definition.kind === 'OperationDefinition' &&
        definition.operation === 'subscription'
      );
    },
    wsLink,
    httpLink,
  );

  $apollo.defaultClient.setLink(from([errorLink, retryLink, splitLink]));
});
  • Credentials: Cookies are included in all requests for authentication
  • Retry Logic: Failed requests retry up to 30 times with exponential backoff
  • Error Handling: Errors trigger a global hook for toast notifications
  • Transport Splitting: Subscriptions use WebSocket, queries/mutations use HTTP

Cache Configuration

$apollo.defaultClient.defaultOptions = {
  watchQuery: {
    fetchPolicy: 'cache-and-network',  // Show cached data, then fetch fresh
  },
  query: {
    fetchPolicy: 'network-only',       // Always fetch fresh data
  },
};

Querying Data

Helper Function

A helper provides easy access to the Apollo Client:
// graphql/getGraphqlClient.ts
export default function getGraphqlClient() {
  const { clients } = useApollo();

  if (!clients?.default) {
    throw Error("missing client");
  }

  return clients.default;
}

Running Queries

import getGraphqlClient from '~/graphql/getGraphqlClient';
import { generateQuery } from '~/graphql/graphqlGen';
import { $ } from '~/generated/zeus';

const { data } = await getGraphqlClient().query({
  query: generateQuery({
    players: [
      {
        where: {
          steam_id: {
            _in: $('steam_ids', '[bigint]!'),
          },
        },
      },
      playerFields,
    ],
  }),
  variables: {
    steam_ids: ['76561198012345678'],
  },
});

Variables with Type Safety

Zeus’s $ function creates typed variables:
import { $ } from '~/generated/zeus';

// Define a variable with name and type
const steamIdVar = $('steam_id', 'bigint!');

// Use in query
const query = generateQuery({
  players_by_pk: [
    { steam_id: steamIdVar },
    playerFields,
  ],
});

Subscriptions

Real-time subscriptions use the same API as queries:
// Example from stores/AuthStore.ts
function subscribeToMe(steam_id: string, callback: () => void) {
  const subscription = getGraphqlClient().subscribe({
    query: generateSubscription({
      players_by_pk: [
        { steam_id: $('steam_id', 'bigint!') },
        meFields,
      ],
    }),
    variables: { steam_id },
  });

  subscription.subscribe({
    next: ({ data }) => {
      me.value = data?.players_by_pk;
      callback();
    },
    error: (error) => {
      console.error('Subscription error:', error);
    },
  });
}
Subscriptions automatically reconnect if the WebSocket connection is lost.

Mutations

Mutations use typed document nodes for full type safety:
import { typedGql } from '~/generated/zeus/typedDocumentNode';

const { data } = await getGraphqlClient().mutate({
  mutation: typedGql('mutation')({
    insert_lobbies_one: [
      {
        object: {},
      },
      {
        id: true,
      },
    ],
  }),
});

const lobbyId = data.insert_lobbies_one.id;

Complex Queries

Zeus supports complex nested queries with aggregations:
// graphql/matchLineupsGraphql.ts (excerpt)
import { $, e_utility_types_enum, order_by, Selector } from "~/generated/zeus";

export const matchLineups = Selector("match_lineups")({
  id: true,
  name: true,
  lineup_players: [
    {
      order_by: [
        { captain: order_by.desc_nulls_last },
        { player: { name: $('order_by_name', 'order_by!') } },
      ],
    },
    {
      captain: true,
      steam_id: true,
      player: {
        ...playerFields,
        // Aggregated stats
        kills_aggregate: [
          {
            where: {
              match_id: { _eq: $('matchId', 'uuid!') },
              team_kill: { _eq: false },
            },
          },
          {
            aggregate: [{}, { count: true }],
          },
        ],
        deaths_aggregate: [
          {
            where: { match_id: { _eq: $('matchId', 'uuid!') } },
          },
          {
            aggregate: [{}, { count: true }],
          },
        ],
        // Nested aggregation with aliases
        __alias: {
          hs_kills_aggregate: {
            kills_aggregate: [
              {
                where: {
                  match_id: { _eq: $('matchId', 'uuid!') },
                  headshot: { _eq: true },
                },
              },
              { aggregate: [{}, { count: true }] },
            ],
          },
        },
      },
    },
  ],
});
  • Nested Aggregations: Count kills, deaths, assists within players
  • Conditional Filtering: Filter aggregations with where clauses
  • Field Aliases: Use __alias to query the same field with different filters
  • Sorting: Multi-level order_by on nested relations
  • Variables: Reusable $matchId variable throughout the query

Error Handling

Global error handling shows user-friendly messages:
nuxtApp.hook('apollo:error', (error) => {
  if (error.graphQLErrors) {
    for (const graphqlError of error.graphQLErrors) {
      // Skip auth errors (handled separately)
      if ([
        'Unauthorized',
        'webhook authentication request',
        'Invalid response from authorization hook',
      ].includes(graphqlError.message)) {
        return;
      }

      // Show error toast
      toast({
        variant: 'destructive',
        title: nuxtApp.$i18n.t('common.error'),
        description: graphqlError.message,
      });
    }
  }
});

Type Safety

The entire GraphQL stack provides end-to-end type safety:
import type { InputType, GraphQLTypes } from '~/generated/zeus';

// Infer types from selectors
type Player = InputType<GraphQLTypes['players'], typeof playerFields>;
type Me = InputType<GraphQLTypes['players'], typeof meFields>;

// Fully typed in stores
export const useAuthStore = defineStore('auth', () => {
  const me = ref<Me>();  // Type-safe player object
  
  // Return type inferred from selector
  return { me };
});

Best Practices

Define field selections once and reuse them:
// Good: Reusable selector
export const playerFields = Selector('players')({ /* fields */ });

// Use everywhere
generateQuery({ players: [{}, playerFields] });
Use descriptive variable names with types:
// Good
const matchId = $('matchId', 'uuid!');
const steamIds = $('steam_ids', '[bigint]!');

// Avoid
const id = $('id', 'uuid!');
Always unsubscribe when components unmount:
const subscription = getGraphqlClient().subscribe(/* ... */);
const unsubscribe = subscription.subscribe(/* ... */);

onUnmounted(() => {
  unsubscribe.unsubscribe();
});

Next Steps

State Management

Learn how GraphQL data flows into Pinia stores

Architecture

Understand the overall system architecture

Build docs developers (and LLMs) love