---
title: "Call a GraphQL API"
audience: "anyone whose backend speaks GraphQL"
description: "You'll read countries from a public GraphQL API through a contract the build checks, handle the call failing and give a client an API key the application code…"
snippet_caption: "app/routes/country/[code]/page.loader.ts"
snippet: "import type { Ctx } from \"@snapfire/fsr/host\";\n\nexport async function load({ params, services }: Ctx<\"/country/{code}\">) {\n  const { country } = await services.countries.country({ code: params.code });\n  return { code: params.code, country };\n}"
---

import { Install } from "@src/ui/Install";

You'll read countries from a public GraphQL API through a contract the build checks, handle the call failing and give a client an API key the application code never sees. The API is `https://countries.trevorblades.com/`, which needs no key.

<Install captured="0.x" />

## Start the app

```shell-session
$ fsr new atlas
```

The pages here render on the server and place no island, so the app needs no framework.

## Write the document

A GraphQL client is one file, `app/clients/<name>.graphql`. It holds the schema's SDL followed by the named operations the app sends. You only need the part of the schema your operations reach:

```graphql title="app/clients/countries.graphql"
type Query {
  continent(code: ID!): Continent
  country(code: ID!): Country
}

type Continent {
  code: ID!
  name: String!
  countries: [Country!]!
}

type Country {
  code: ID!
  name: String!
  native: String!
  capital: String
  currency: String
  emoji: String!
  continent: Continent!
  languages: [Language!]!
}

type Language {
  code: ID!
  name: String!
}

query Continent($code: ID!) {
  continent(code: $code) {
    name
    countries {
      code
      name
      emoji
    }
  }
}

query Country($code: ID!) {
  country(code: $code) {
    name
    native
    capital
    currency
    emoji
    languages {
      name
    }
  }
}
```

The file's name is the service's name, so this one is `services.countries`. Each operation is a method named in lowerCamel, so `Country` is `services.countries.country`. Its variables are the arguments.

## Point the client at the endpoint

```toml title="config/app.toml"
[clients.countries]
base_url = "https://countries.trevorblades.com/"
```

`base_url` is where every operation is posted. The `.graphql` extension picks the GraphQL transport, which posts the operation with the arguments as its variables and answers the response's `data`. Build and the report names it:

```shell-session
$ fsr build atlas/app --verbose
services  countries              graphql     clients/countries.graphql
```

## Read the generated types

Each answer is typed from the operation's selection, not from the whole schema:

```ts title="app/generated/services.d.ts"
export interface CountryQueryCountryLanguages {
  name: string;
}

export interface CountryQueryCountry {
  name: string;
  native: string;
  capital: string | null;
  currency: string | null;
  emoji: string;
  languages: CountryQueryCountryLanguages[];
}

export interface CountryQuery {
  country: CountryQueryCountry | null;
}

export interface Services {
  countries: {
    continent(args: { code: string; }): Promise<ContinentQuery>;
    country(args: { code: string; }): Promise<CountryQuery>;
  };
}
```

`country` is nullable because the schema says `country(code: ID!): Country`. `capital` is nullable for the same reason. Reading a field the operation didn't select is a type error:

```shell-session
$ fsr check atlas/app
routes/country/[code]/page.tsx(10,57): error TS2339: Property 'phone' does not exist on type 'CountryQueryCountry'.
```

The build also validates every operation against the schema. Misspell `capital` in the query and it stops there:

```shell-session
$ fsr check atlas/app
clients/countries.graphql: countries.graphql: Error: type `Country` does not have a field `capitol`
```

## List a continent

```ts title="app/routes/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ services }: Ctx<"/">) {
  const { continent } = await services.countries.continent({ code: "EU" });
  return { continent };
}
```

```tsx title="app/routes/page.tsx"
import { Link } from "@snapfire/fsr/components";
import type { RootProps } from "@generated/client";

export default function Index({ continent }: RootProps) {
  if (continent === null) return <p>There is no such continent.</p>;
  return (
    <section>
      <h1>{continent.name}</h1>
      <ul>
        {continent.countries.map((c) => (
          <li key={c.code}>
            {c.emoji} <Link href={`/country/${c.code}`}>{c.name}</Link>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

The page checks `null` before it reads a field, since the type says the answer can be `null`.

## Show one country

```ts title="app/routes/country/[code]/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";
import { fail } from "@snapfire/fsr/host";

export async function load({ params, services }: Ctx<"/country/{code}">) {
  const { country } = await services.countries.country({ code: params.code });
  if (country === null) fail("not_found", `no country with the code ${params.code}`);
  return { country };
}
```

```tsx title="app/routes/country/[code]/page.tsx"
import type { CountryCodeProps } from "@generated/client";

export default function Country({ country }: CountryCodeProps) {
  return (
    <article>
      <h1>
        {country.emoji} {country.name}
      </h1>
      <p>In its own language: {country.native}</p>
      <p>Capital: {country.capital ?? "none"}</p>
      <p>Currency: {country.currency ?? "none"}</p>
      <p>Languages: {country.languages.map((l) => l.name).join(", ")}</p>
    </article>
  );
}
```

Run `fsr dev atlas/app` and open `/`. You get Europe with 53 countries. Click France and you get Paris, EUR and French. Open `/country/XX` and you get a 404 with "no country with the code XX", since the API answers `null` for a code it doesn't know and the loader turns that into `not_found`. The page only renders a country that exists.

## When the call fails

A failed call has a kind. The GraphQL transport reads the first error's `extensions.code`:

| `extensions.code` | Kind |
| --- | --- |
| `NOT_FOUND` | `not_found` |
| `UNAUTHENTICATED` | `unauthorized` |
| `FORBIDDEN` | `forbidden` |
| `BAD_USER_INPUT`, `BAD_REQUEST`, `GRAPHQL_VALIDATION_FAILED`, `GRAPHQL_PARSE_FAILED` | `invalid` |
| none or anything else | `internal` |

A non-2xx answer without `errors` maps the way it does over HTTP. A backend that doesn't answer at all is `unavailable`.

Try that last one. Point the client at a port nothing listens on:

```toml title="config/app.toml"
[clients.countries]
base_url = "http://127.0.0.1:3429/"
```

Reload `/`. The loader failed, so the route's error component renders with a 503. Its message names `countries.continent` as `unavailable`.

The loader never saw a connection error. The transport turned it into a kind before the loader's `await` returned.

To answer the failure yourself, catch it in the loader and read its `kind`:

```ts title="app/routes/page.loader.ts"
import type { Ctx, Failure } from "@snapfire/fsr/host";

export async function load({ services }: Ctx<"/">) {
  try {
    const { continent } = await services.countries.continent({ code: "EU" });
    return { continent, failed: null };
  } catch (e) {
    return { continent: null, failed: (e as Failure).kind };
  }
}
```

```tsx title="app/routes/page.tsx"
import { Link } from "@snapfire/fsr/components";
import type { RootProps } from "@generated/client";

export default function Index({ continent, failed }: RootProps) {
  if (failed !== null) return <p>The atlas is not answering ({failed}). Try again in a minute.</p>;
  if (continent === null) return <p>There is no such continent.</p>;
  return (
    <section>
      <h1>{continent.name}</h1>
      <ul>
        {continent.countries.map((c) => (
          <li key={c.code}>
            {c.emoji} <Link href={`/country/${c.code}`}>{c.name}</Link>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

With the dead port, `/` now reads "The atlas is not answering (unavailable). Try again in a minute." Put `base_url` back when you're done.

## Send an API key

Many GraphQL APIs want a key that belongs to the application, not to a visitor. Declare it under `[secrets]` and name it on the client:

```toml title="config/app.toml"
[secrets.atlas]
description = "The atlas API key"

[clients.countries]
base_url = "https://countries.trevorblades.com/"
credential = { secret = "atlas", header = "x-api-key" }
```

Without `header` the key goes as `Authorization: Bearer <value>`. `query = "key"` sends it as a query parameter instead. The key sits on the client's transport, so no loader, trace or interceptor sees it. There is no argument a loader could pass it in.

The key needs a value or the host stops at boot:

```
`secrets.atlas.value` has no value and a client sends it; set C5_SECRETS__ATLAS__VALUE or write it encrypted in a configuration file the host reads
```

A key for your own machine can go in `config/development.toml` as plain text:

```toml title="config/development.toml"
[secrets.atlas]
value = "a-key-for-this-machine"
```

Outside development a plain-text value in a file stops the host, since that file is in the repository. A deployment sets `C5_SECRETS__ATLAS__VALUE` instead. `fsr doctor` tells you what the production ladder still expects:

```shell-session
$ fsr doctor atlas/app --env production
expects      session.key: the files hold the placeholder; this deployment needs C5_SESSION__KEY when it starts
expects      secrets.atlas.value: no file sets it; this deployment needs C5_SECRETS__ATLAS__VALUE when it starts
doctor       22 checks, nothing to report
```

A client on the `mock` transport sends nothing, so your tests run with no key at all.

Next up: [380. Embed a video](https://snapfirers.com/fsr/learn/380-embed-a-video).
