Skip to content
DasPeTe Tutorials

A Fast GraphQL API With Fastify and Yoga · Part 1 of 8 in the series

Part 1: Fastify Meets GraphQL Yoga

Set up a TypeScript project that runs without a build step, put GraphQL Yoga on a Fastify server and send it your first queries.

Quest objectives

  • Know what you build in this series
  • Run TypeScript files directly with Node.js 24
  • Serve GraphQL Yoga from a Fastify route
  • Send queries with curl and with GraphiQL
  • Get one readable log line per request

In this series you build the API behind a guild board. Guilds post quests, adventurers accept and complete them, and every board that is open in a browser updates the moment something changes. By the last part the API batches its database queries, runs as several instances, caches whole responses and turns away queries that would cost too much.

GraphQL lets a client ask for exactly the fields it needs, nested as deep as it likes, in one request to one address. The schema describes everything the API can do, with types. The price is on the server: it has to answer any combination of those fields, and answer it quickly. That is what this series is about.

The tools

  • Fastify is a fast HTTP server for Node.js with a structured logger built in.

  • GraphQL Yoga is a GraphQL server built on the Fetch API. It speaks subscriptions over Server-Sent Events out of the box, and its plugins cover caching, limits and more.

  • TypeScript checks the code. Node.js runs it directly, so there is no build step.

The series

  1. Fastify meets GraphQL Yoga (this part)

  2. A database with PostgreSQL and Drizzle

  3. The schema, typed by GraphQL Codegen, and the N+1 problem

  4. Batching with DataLoader

  5. Mutations and live updates with subscriptions

  6. Subscriptions across instances with Valkey

  7. Caching whole responses

  8. Query limits and persisted operations

Every command in this series was run while it was written, and the output you see is the output it printed.

What you need

Node.js 24, pnpm, and a terminal with curl. From the second part on you also need Docker, for PostgreSQL and later Valkey. Check your versions:

~
$ node --versionv24.22.2$ pnpm --version12.16.1

If pnpm is missing, install it with npm, which comes with Node.js:

~
$ npm install --global --allow-scripts=pnpm pnpmadded 2 packages in 1s

Create the project

Make a folder for the API and let pnpm write a package.json:

~/projects
$ mkdir guild-api$ cd guild-api$ pnpm initWrote to /home/robin/projects/guild-api/package.json{  "name": "guild-api",  "version": "1.0.0",  "description": "",  "main": "index.js",  "scripts": {    "test": "echo \"Error: no test specified\" && exit 1"  },  "keywords": [],  "author": "",  "license": "ISC",  "devEngines": {    "packageManager": {      "name": "pnpm",      "version": "12.16.1",      "onFail": "download"    }  },  "packageManager": "pnpm@12.16.1",  "type": "module"}

"type": "module" is already set, so Node.js treats every file as an ES module with import and export. Now add the packages:

~/projects/guild-api
$ pnpm add fastify graphql-yoga graphql @graphql-hive/loggerPackages: +72Progress: resolved 72, reused 0, downloaded 72, added 72, donedependencies:+ @graphql-hive/logger 1.1.1+ fastify 5.12.8+ graphql 17.0.2+ graphql-yoga 6.0.1Done in 414ms using pnpm v12.16.1$ pnpm add -D typescript @types/node@24 pino-prettyPackages: +18Progress: resolved 18, reused 26, downloaded 18, added 18, donedevDependencies:+ @types/node 24.19.2+ pino-pretty 13.2.0+ typescript 7.0.2Done in 401ms using pnpm v12.16.1

graphql is the reference implementation of GraphQL that Yoga builds on. Yoga 6 logs through @graphql-hive/logger, and you will connect that to Fastify's logger in a moment. TypeScript only checks the code, @types/node describes Node 24's APIs to it, and pino-pretty makes the logs readable while you develop.

TypeScript without a build step

Node.js 24 runs .ts files itself: it removes the types and runs what is left. It does not check them, though, so tsc does that and writes nothing. Create tsconfig.json:

tsconfig.json
{
  "compilerOptions": {
    "module": "nodenext",
    "target": "esnext",
    "types": ["node"],
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["src", "scripts", "*.ts"]
}

Three options make the code fit Node's type stripping. erasableSyntaxOnly forbids TypeScript features that would need real code instead of being removed, such as enum and namespaces. verbatimModuleSyntax makes you write import type for imports that are only types, because Node cannot tell them apart on its own. And allowImportingTsExtensions lets you import ./db.ts with its real file name, which is what Node needs to find it.

The settings go in a .env file, which Node also reads itself, and that file stays out of git:

.env
PORT=4000
.gitignore
node_modules
.env

Replace the scripts in package.json with these:

package.json
"scripts": {
    "dev": "node --env-file=.env --watch src/server.ts | pino-pretty",
    "start": "node --env-file=.env src/server.ts",
    "typecheck": "tsc"
  },

pnpm dev restarts the server whenever a file changes and turns the JSON logs into readable lines. pnpm start is for production, with plain JSON logs that a log collector can read. pnpm typecheck runs tsc.

The server

Create src/server.ts:

src/server.ts
import Fastify, { LogController, type FastifyReply, type FastifyRequest } from 'fastify';
import { createSchema, createYoga } from 'graphql-yoga';
import { Logger } from '@graphql-hive/logger';
import { PinoLogWriter } from '@graphql-hive/logger/writers/pino';

const app = Fastify({
  logger: true,
  logController: new LogController({ disableRequestLogging: true }),
});

const yoga = createYoga<{ req: FastifyRequest; reply: FastifyReply }>({
  schema: createSchema({
    typeDefs: /* GraphQL */ `
      type Query {
        hello: String!
      }
    `,
    resolvers: {
      Query: {
        hello: () => 'Welcome to the guild board!',
      },
    },
  }),
  logging: new Logger({ writers: [new PinoLogWriter(app.log)] }),
});

app.route({
  url: yoga.graphqlEndpoint,
  method: ['GET', 'POST', 'OPTIONS'],
  handler: (req, reply) => yoga.handleNodeRequestAndResponse(req, reply, { req, reply }),
});

await app.listen({ port: Number(process.env.PORT) });

From the top:

  • logger: true gives Fastify its logger, pino, which writes one JSON object per line. By default Fastify logs two lines for every request. The LogController turns those off, because Yoga writes a better one for every GraphQL request.

  • createYoga gets a schema with one field, hello, and the resolver that answers it. The type argument says what every resolver will find in its context: Fastify's request and reply.

  • logging sends Yoga's log lines into Fastify's logger, so they look the same and carry Fastify's request id.

  • The route answers /graphql, Yoga's endpoint. It hands Node's request and Fastify's reply to Yoga, which writes the response itself. The last argument is the server context from the type argument.

Start it:

~/projects/guild-api
$ pnpm dev[15:17:24.082] INFO (139): Server listening at http://[::1]:4000[15:17:24.083] INFO (139): Server listening at http://127.0.0.1:4000

The first queries

In a second terminal, send a query. GraphQL travels as JSON in a POST request, and a query can also go in the address of a GET request:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ hello }"}'{"data":{"hello":"Welcome to the guild board!"}}$ curl -s 'localhost:4000/graphql?query=%7Bhello%7D'{"data":{"hello":"Welcome to the guild board!"}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ goodbye }"}'{"errors":[{"message":"Cannot query field \"goodbye\" on type \"Query\".","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}

The third query asks for a field that does not exist. Yoga checks every operation against the schema before it runs anything and answers with an error that says where the problem is. Back in the first terminal, there is one line for every request:

Terminal
[15:17:26.330] INFO (139): Request processed    requestId: "req-1"    method: "POST"    path: "/graphql"    operationType: "query"    status: 200    errorCount: 0    durationMs: 5[15:17:26.341] INFO (139): Request processed    requestId: "req-2"    method: "GET"    path: "/graphql"    operationType: "query"    status: 200    errorCount: 0    durationMs: 1[15:17:26.349] INFO (139): Request processed    requestId: "req-3"    method: "POST"    path: "/graphql"    operationType: "query"    status: 200    errorCount: 1    durationMs: 1

The request id, whether it was a query, a mutation or a subscription, how many errors it had and how long it took. You will lean on durationMs a lot in this series.

GraphiQL

Open localhost:4000/graphql in your browser. A browser asks for HTML, so Yoga answers with GraphiQL, a playground for your API. Type { hello } on the left and click the run button:

GraphiQL runs the query and shows the answer on the right.Open the screenshot in full size
GraphiQL runs the query and shows the answer on the right.

GraphiQL knows the schema, so it completes field names while you type and shows their documentation. It is the quickest way to try out a query.

The API answers, but only with a greeting. In the next part you give it something to talk about: a PostgreSQL database with guilds, adventurers and quests.

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.