js

Schema-First GraphQL APIs with Fastify, Mercurius, and Pothos

Learn how to build type-safe, efficient GraphQL APIs using a schema-first approach with Fastify, Mercurius, and Pothos.

Schema-First GraphQL APIs with Fastify, Mercurius, and Pothos

I was building yet another API, and the usual friction was there. The back-and-forth between frontend and backend teams about what data should look like, the constant tweaking of types, and the creeping feeling that the code and the contract were slowly drifting apart. It felt inefficient. That’s when I decided to stop fighting the process and build the contract first. This is a guide to building APIs where the schema leads the way, using Fastify, Mercurius, and Pothos GraphQL. If you’re tired of guesswork in your API development, follow along.

Why start with the schema? Think of it as the blueprint for your data highway. You wouldn’t start pouring concrete without a plan, right? Defining your GraphQL schema first creates a single, authoritative source of truth. Frontend developers can start building against a real, executable schema immediately. Backend developers have a clear target. It aligns everyone from day one.

This approach is often called schema-first or schema-driven development. The core idea is simple: you write your GraphQL Schema Definition Language (SDL) files first. Then, you write code to fulfill that contract. It’s the opposite of code-first, where your schema is generated from your code. Each has merits, but schema-first gives you precise control and early collaboration.

So, why this specific stack? Fastify is our foundation because it’s fast and lean. It handles HTTP requests with minimal overhead. Mercurius is the bridge that lets Fastify speak GraphQL fluently. It’s built for performance and integrates seamlessly. Pothos is the magic glue. It lets us build our schema in TypeScript code that is type-safe and intuitive, while still adhering to our initial SDL blueprint.

Ready to see how these pieces fit together? Let’s start by setting up our project.

First, create a new directory and initialize it. We’ll use TypeScript for end-to-end type safety.

mkdir schema-first-api
cd schema-first-api
npm init -y
npm install fastify mercurius @pothos/core graphql
npm install -D typescript @types/node tsx

Next, we need a tsconfig.json file to configure TypeScript. This setup is optimized for modern Node.js.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "strict": true,
    "outDir": "./dist"
  }
}

With the basics in place, let’s define our schema. We’ll start with a simple example: a blog with users and posts. Create a file called schema.graphql. This is our blueprint.

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
}

type Query {
  user(id: ID!): User
  posts: [Post!]!
}

Now, how do we connect this SDL to running code? This is where Pothos shines. We’ll create a builder that uses this SDL file to generate type-safe GraphQL types. Create a file named builder.ts.

import SchemaBuilder from '@pothos/core';
import { readFileSync } from 'fs';
import { join } from 'path';

// Read our schema file
const typeDefs = readFileSync(join(__dirname, 'schema.graphql'), 'utf-8');

// Initialize the Pothos builder with our SDL
export const builder = new SchemaBuilder<{
  Scalars: {
    ID: { Input: string; Output: string };
    String: { Input: string; Output: string };
  };
}>({ schema: typeDefs });

// We'll build our queries and types on this builder

Have you ever wondered how to keep your resolver code organized and type-checked? Pothos allows us to define resolvers that are explicitly tied to the types in our SDL. Let’s define a User type resolver. Create user.schema.ts.

import { builder } from './builder';

// Define the User object type based on our SDL
builder.objectType('User', {
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    email: t.exposeString('email'),
    // The posts field requires a custom resolver
    posts: t.field({
      type: ['Post'],
      resolve: (parent) => {
        // In a real app, you'd fetch posts for this user from a database
        // For now, we return mock data
        return [{ id: '1', title: 'Hello World', content: 'My first post' }];
      },
    }),
  }),
});

We need to do the same for the Post type and our Query. But here’s a question: what happens when a client asks for a user and all their posts? Without careful planning, you might trigger one database query for the user and then a separate query for each of their posts. This is the infamous N+1 problem.

The solution is a pattern called batching. We use a tool called a DataLoader. It waits for all requests for similar data within a single tick of the event loop, then batches them into one efficient query. Let’s create a simple DataLoader for users. First, install it: npm install dataloader.

Create a loaders.ts file.

import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// A batch function that fetches many users by their IDs
const batchUsers = async (ids: string[]) => {
  const users = await prisma.user.findMany({
    where: { id: { in: ids } },
  });
  // DataLoader requires the results to be in the same order as the input keys
  const userMap = new Map(users.map(user => [user.id, user]));
  return ids.map(id => userMap.get(id));
};

// Create the DataLoader instance
export const userLoader = new DataLoader(batchUsers);

Now, we can use this userLoader in our Post resolver to efficiently fetch the author. Update your Post type definition.

builder.objectType('Post', {
  fields: (t) => ({
    id: t.exposeID('id'),
    title: t.exposeString('title'),
    content: t.exposeString('content'),
    author: t.field({
      type: 'User',
      resolve: async (post) => {
        // Load the author. DataLoader will batch multiple author requests.
        return userLoader.load(post.authorId);
      },
    }),
  }),
});

See how the DataLoader abstracts away the complexity? The resolver code stays clean, and we get automatic performance optimization. Now, let’s wire everything up to a Fastify server. Create an index.ts file.

import Fastify from 'fastify';
import mercurius from 'mercurius';
import { builder } from './builder';
import { userLoader } from './loaders';

// Build the final GraphQL schema from our Pothos definitions
const schema = builder.toSchema();

const app = Fastify();

// Register Mercurius (GraphQL) with Fastify
app.register(mercurius, {
  schema,
  graphiql: true, // Enables the GraphiQL IDE in development
  context: () => ({
    // Make our loaders available in the GraphQL context
    loaders: { userLoader },
  }),
});

app.listen({ port: 3000 }, (err, address) => {
  if (err) {
    console.error(err);
    process.exit(1);
  }
  console.log(`Server listening at ${address}`);
});

Start the server with npx tsx index.ts. Navigate to http://localhost:3000/graphiql. You now have a fully functional, schema-first GraphQL API. You can run queries defined in your original schema.graphql file, and they are resolved by our type-safe Pothos code, with efficient data loading.

This is just the beginning. From here, you can add authentication by checking context in your resolvers, implement real-time subscriptions with Mercurius, or add query complexity analysis to prevent overly expensive queries. The schema you defined first remains your guide, ensuring every new feature fits the agreed-upon contract.

The beauty of this method is in the clarity it brings. The schema is no longer an afterthought; it’s the foundation. It forces important conversations about data design to happen early. It creates a living document that developers on all sides of the project can reference and trust.

I encourage you to take this foundation and build upon it. Experiment with adding mutations to create data, or try integrating a database like PostgreSQL with Prisma. The combination of a clear schema, Fastify’s speed, and Pothos’s type safety is incredibly powerful for building reliable and maintainable APIs.

What problem in your current API workflow would a schema-first approach solve? Try it out. If this guide helped clarify a better path for your projects, please share it with your team or leave a comment with your thoughts. Let’s build more predictable software, together.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: graphql,fastify,schema-first,pothos,api development



Similar Posts
Blog Image
How to Use Agenda with NestJS for Scalable Background Job Scheduling

Learn how to integrate Agenda with NestJS to handle background tasks like email scheduling and data cleanup efficiently.

Blog Image
Complete Event-Driven Microservices Architecture Guide: NestJS, RabbitMQ, and MongoDB Integration

Learn to build scalable event-driven microservices with NestJS, RabbitMQ & MongoDB. Master CQRS, sagas, error handling & deployment strategies.

Blog Image
Complete Guide to Next.js Prisma Integration: Build Type-Safe Full-Stack TypeScript Applications

Build powerful full-stack TypeScript apps with Next.js and Prisma integration. Learn type-safe database operations, API routes, and seamless development workflows.

Blog Image
How to Build a Production-Ready API Gateway with Fastify and TypeScript

Learn how to create a secure, scalable API gateway using Fastify, TypeScript, and Consul for modern microservices architecture.

Blog Image
Complete Guide: Building Full-Stack TypeScript Apps with Next.js and Prisma ORM Integration

Learn to integrate Next.js with Prisma ORM for type-safe full-stack apps. Get step-by-step setup, TypeScript benefits, and best practices guide.

Blog Image
Build Full-Stack Next.js Applications with Prisma: Complete Integration Guide for Type-Safe Database Operations

Learn how to integrate Next.js with Prisma ORM for powerful full-stack applications. Get type-safe database operations, seamless API routes, and faster development workflows.