Skip to content
View as Markdown

GraphQL Conventions ​

Type-safe GraphQL via gql.tada. The frontend never hand-writes types — generated bindings come from pnpm generate (step 2 of the workflow) reading the deployed backend schema.

Setup ​

Every GraphQL-aware module imports from the local @/graphql barrel:

ts
import { graphql, type FragmentOf, readFragment } from "@/graphql";

@/graphql is generated. Don't import gql from urql or gql.tada directly — go through the barrel so types stay coherent.

The urql client is wired in your app's root (App.tsx); pages don't construct their own client.

Built-in operations vs custom resolvers ​

The Tailor Platform auto-generates per-entity queries when the resolver doc opts in via gqlOperations: "query":

  • <entity>(id: ID!) — get one
  • <entities> — list, returned as a connection (edges { node { ... } }, plus pageInfo)

Mutations are always custom — they come from resolver docs (Tier 3) and live in backend/src/resolvers/. The generated types appear in @/graphql after pnpm generate.

Fragment collocation ​

Each component owns its fragment. The parent page imports the fragment and composes it into the page query. This is the single most important GraphQL convention in this skill.

tsx
// components/order-summary.tsx
import { graphql, type FragmentOf, readFragment } from "@/graphql";

export const OrderSummaryFragment = graphql(`
  fragment OrderSummary on Order {
    id
    number
    status
    total
  }
`);

export const OrderSummary = ({ order }: { order: FragmentOf<typeof OrderSummaryFragment> }) => {
  const data = readFragment(OrderSummaryFragment, order);
  return (
    <div>
      {data.number} — {data.status}
    </div>
  );
};
tsx
// pages/orders/[id]/page.tsx
import { graphql } from "@/graphql";
import { OrderSummary, OrderSummaryFragment } from "@/components/order-summary";

const OrderQuery = graphql(
  `
    query Order($id: ID!) {
      order(id: $id) {
        ...OrderSummary
      }
    }
  `,
  [OrderSummaryFragment],
);

Rules:

  • Each component file exports <Name>Fragment alongside the component.
  • The component's prop type is FragmentOf<typeof <Name>Fragment>. Always call readFragment(<Name>Fragment, prop) once to read it.
  • The parent's query lists the fragment in the second graphql(...) argument (the dependency array). Forgetting the dep array works at runtime but loses type narrowing.
  • Don't pass raw fields between components — pass the fragment value. This keeps types and queries in lockstep.

Connection pattern (lists) ​

Built-in <entities> queries return a connection. Use edges { node { ... } }:

tsx
const OrdersQuery = graphql(
  `
    query Orders($first: Int, $after: String) {
      orders(first: $first, after: $after) {
        edges {
          node {
            ...OrderSummary
          }
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  `,
  [OrderSummaryFragment],
);

const [{ data }] = useQuery({ query: OrdersQuery, variables: { first: 25 } });
const orders = data?.orders.edges.map((e) => e.node) ?? [];

For pnpm gql-tada:check to pass, always destructure with optional chaining and provide a fallback (?? []).

Wiring to DataTable (sort, filters, pagination) ​

For standard list pages, do not hand-roll only first / after. Compose AppShell DataTable + useCollectionVariables + useDataTable: derive query variables from variables.pagination, variables.query, variables.order, map edges / pageInfo / total into useDataTable’s data. See list-dense-scan and DataTable.

When @tailor-platform/sdk-plugin-app-shell codegen is enabled, pass generated tableMetadata into useCollectionVariables so variables.query / variables.order are typed against your Tailor GraphQL inputs (upstream data-table.md Typed query variables).

Mutations and cache invalidation ​

Mutations from custom resolvers appear in @/graphql after pnpm generate:

tsx
import { useMutation } from "urql";
import { graphql } from "@/graphql";
import { useToast } from "@tailor-platform/app-shell";

const CreateOrderMutation = graphql(`
  mutation CreateOrder($input: CreateOrderInput!) {
    createOrder(input: $input) {
      id
      number
    }
  }
`);

function CreateOrderButton() {
  const toast = useToast();
  const [{ fetching }, createOrder] = useMutation(CreateOrderMutation);

  const handleSubmit = async (input: CreateOrderInput) => {
    const result = await createOrder({ input });
    if (result.error) {
      toast.error(`Failed to create order: ${result.error.message}`);
      return;
    }
    toast.success(`Order ${result.data!.createOrder.number} created`);
  };
  // ...
}

additionalTypenames ​

When a mutation creates/updates/deletes an entity that another query already has cached, urql won't know to refetch unless you pass additionalTypenames. Use it on both the query and the mutation when they share a typename:

tsx
// Query side — declares it cares about the Order typename
const [{ data }] = useQuery({
  query: OrdersQuery,
  variables: { first: 25 },
  context: { additionalTypenames: ["Order"] },
});

// Mutation side — declares it produces Order writes
const [, createOrder] = useMutation(CreateOrderMutation);
await createOrder(
  { input },
  { additionalTypenames: ["Order"] }, // triggers refetch of any query subscribed to 'Order'
);

Skip additionalTypenames only when the mutation's return type already includes the affected entity and every cached query for it touched the same id — urql will then refresh by id. When in doubt, add it.

Optimistic updates ​

Use sparingly — only for actions that:

  1. Are reversible (rollback is cheap on error)
  2. The user expects to feel instant (toggling a flag, reordering a list)

Prefer plain pending-state UI (button spinner) for everything else. See interaction/toast.md and interaction/confirm.md for feedback patterns.

Page conventions ​

Every page component:

  1. Is the default export of page.tsx (or index.tsx depending on routing convention — match siblings in src/pages/).

  2. Sets appShellPageProps with at least meta.title:

    tsx
    export default function OrdersListPage() {
      /* ... */
    }
    OrdersListPage.appShellPageProps = {
      meta: { title: "Orders" },
    };
  3. Uses appShellPageProps.guards for permission gates.

Quick reference ​

ConcernAnswer
Where do generated types come from?@/graphql barrel, populated by pnpm generate
How do I add a field to a card?Add it to the component's fragment, not the page query
Why isn't my list refreshing after a mutation?Add additionalTypenames: ['<Type>'] on both query and mutation
Connection vs array?Always connection (edges { node }) for built-in <entities> queries; check the schema for custom resolvers
Pagination / sort / toolbar filters together?useCollectionVariables + DataTable; see § Wiring to DataTable above
Where's the urql client configured?App.tsx (your app's root)