Build a Type-Safe GraphQL API with Next.js App Router, Yoga, and Pothos

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

GraphQL with full TypeScript inference — no code generation needed. Pothos is a schema builder that gives you autocompletion and type safety across your entire GraphQL API, while GraphQL Yoga provides a spec-compliant, framework-agnostic server. In this tutorial, you will combine both with Next.js 15 App Router to build a production-ready bookmark manager API.

What You Will Learn

By the end of this tutorial, you will:

  • Set up GraphQL Yoga as a route handler inside Next.js 15 App Router
  • Define a code-first GraphQL schema with Pothos — no SDL files, no codegen
  • Build queries and mutations with full TypeScript autocompletion
  • Add authentication middleware using context functions
  • Implement input validation with Zod through Pothos plugins
  • Connect a React client using urql for data fetching
  • Handle errors and loading states the GraphQL way

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript experience (types, generics, interfaces)
  • Next.js App Router familiarity (route handlers, Server Components)
  • GraphQL basics — understanding of queries, mutations, and schemas is helpful but not required
  • A code editor — VS Code or Cursor recommended

Why GraphQL in 2026?

REST works well for simple CRUD, but as your frontend grows, you run into familiar problems: over-fetching data you do not need, under-fetching and making extra requests, versioning headaches, and no standard way to describe your API.

GraphQL solves these by letting the client ask for exactly what it needs in a single request. But traditional GraphQL setups require maintaining separate SDL schema files and running code generators to keep TypeScript types in sync.

Pothos changes this. You define your schema in TypeScript, and it infers all the types automatically. Combined with GraphQL Yoga (a lightweight, spec-compliant server) and Next.js App Router, you get a GraphQL API that is type-safe from database to UI — with zero code generation.


What You Will Build

A bookmark manager with:

  • A GraphQL API exposed via Next.js route handlers
  • Queries to list and search bookmarks
  • Mutations to create, update, and delete bookmarks
  • User authentication via context
  • A React frontend using urql to consume the API

Here is the final project structure:

bookmark-app/
├── app/
│   ├── api/
│   │   └── graphql/
│   │       └── route.ts          # GraphQL Yoga handler
│   ├── bookmarks/
│   │   └── page.tsx              # Bookmarks UI
│   └── layout.tsx
├── graphql/
│   ├── builder.ts                # Pothos schema builder
│   ├── schema.ts                 # Root schema
│   ├── types/
│   │   ├── bookmark.ts           # Bookmark type + resolvers
│   │   └── user.ts               # User type
│   └── context.ts                # Request context
├── lib/
│   ├── db.ts                     # In-memory database
│   └── urql.ts                   # urql client setup
└── package.json

Step 1: Create the Next.js Project

Scaffold a new Next.js 15 application with TypeScript:

npx create-next-app@latest bookmark-app --typescript --tailwind --app --src-dir=false
cd bookmark-app

Select the defaults when prompted. Then install the GraphQL dependencies:

npm install graphql graphql-yoga @pothos/core @pothos/plugin-zod zod
npm install urql @urql/next graphql-tag

Here is what each package does:

PackagePurpose
graphqlThe GraphQL reference implementation
graphql-yogaLightweight, spec-compliant GraphQL server
@pothos/coreCode-first schema builder with type inference
@pothos/plugin-zodZod validation integration for Pothos
zodRuntime schema validation
urqlLightweight GraphQL client for React
@urql/nextNext.js-specific urql bindings
graphql-tagTagged template literals for GraphQL queries

Step 2: Set Up the In-Memory Database

For this tutorial, you will use an in-memory store. In production, replace this with Prisma, Drizzle, or any database layer.

Create lib/db.ts:

export interface User {
  id: string;
  name: string;
  email: string;
}
 
export interface Bookmark {
  id: string;
  url: string;
  title: string;
  description: string | null;
  tags: string[];
  userId: string;
  createdAt: Date;
  updatedAt: Date;
}
 
// Seed data
const users: User[] = [
  { id: "1", name: "Alice", email: "alice@example.com" },
  { id: "2", name: "Bob", email: "bob@example.com" },
];
 
const bookmarks: Bookmark[] = [
  {
    id: "1",
    url: "https://graphql.org",
    title: "GraphQL Official Site",
    description: "The official GraphQL documentation and specification",
    tags: ["graphql", "api", "documentation"],
    userId: "1",
    createdAt: new Date("2026-01-15"),
    updatedAt: new Date("2026-01-15"),
  },
  {
    id: "2",
    url: "https://nextjs.org",
    title: "Next.js by Vercel",
    description: "The React framework for the web",
    tags: ["nextjs", "react", "framework"],
    userId: "1",
    createdAt: new Date("2026-02-01"),
    updatedAt: new Date("2026-02-01"),
  },
  {
    id: "3",
    url: "https://pothos-graphql.dev",
    title: "Pothos GraphQL",
    description: "Code-first GraphQL schema builder for TypeScript",
    tags: ["graphql", "typescript", "schema"],
    userId: "2",
    createdAt: new Date("2026-02-20"),
    updatedAt: new Date("2026-02-20"),
  },
];
 
let nextId = 4;
 
export const db = {
  users: {
    findMany: () => users,
    findById: (id: string) => users.find((u) => u.id === id) ?? null,
    findByEmail: (email: string) => users.find((u) => u.email === email) ?? null,
  },
  bookmarks: {
    findMany: (filters?: { userId?: string; tag?: string; search?: string }) => {
      let result = [...bookmarks];
      if (filters?.userId) {
        result = result.filter((b) => b.userId === filters.userId);
      }
      if (filters?.tag) {
        result = result.filter((b) => b.tags.includes(filters.tag!));
      }
      if (filters?.search) {
        const q = filters.search.toLowerCase();
        result = result.filter(
          (b) =>
            b.title.toLowerCase().includes(q) ||
            b.url.toLowerCase().includes(q) ||
            b.description?.toLowerCase().includes(q)
        );
      }
      return result;
    },
    findById: (id: string) => bookmarks.find((b) => b.id === id) ?? null,
    create: (data: Omit<Bookmark, "id" | "createdAt" | "updatedAt">) => {
      const bookmark: Bookmark = {
        ...data,
        id: String(nextId++),
        createdAt: new Date(),
        updatedAt: new Date(),
      };
      bookmarks.push(bookmark);
      return bookmark;
    },
    update: (id: string, data: Partial<Omit<Bookmark, "id" | "createdAt">>) => {
      const index = bookmarks.findIndex((b) => b.id === id);
      if (index === -1) return null;
      bookmarks[index] = { ...bookmarks[index], ...data, updatedAt: new Date() };
      return bookmarks[index];
    },
    delete: (id: string) => {
      const index = bookmarks.findIndex((b) => b.id === id);
      if (index === -1) return false;
      bookmarks.splice(index, 1);
      return true;
    },
  },
};

This gives you a clean data layer with typed queries. The same interface would work if you swapped in Drizzle or Prisma later.


Step 3: Define the Request Context

GraphQL context is where you put per-request data like the authenticated user, database connections, and request metadata.

Create graphql/context.ts:

import { db, type User } from "@/lib/db";
 
export interface GraphQLContext {
  currentUser: User | null;
  db: typeof db;
}
 
export function createContext(request: Request): GraphQLContext {
  // In production, extract and verify a JWT or session token here
  const userId = request.headers.get("x-user-id");
  const currentUser = userId ? db.users.findById(userId) : null;
 
  return {
    currentUser,
    db,
  };
}

For this tutorial, you simulate authentication by reading an x-user-id header. In production, you would validate a JWT, check a session cookie, or use a library like NextAuth.js.


Step 4: Create the Pothos Schema Builder

The schema builder is the core of Pothos. It is where you configure plugins, define the context type, and set up the builder instance that all your types will use.

Create graphql/builder.ts:

import SchemaBuilder from "@pothos/core";
import ZodPlugin from "@pothos/plugin-zod";
import type { GraphQLContext } from "./context";
 
export const builder = new SchemaBuilder<{
  Context: GraphQLContext;
  Scalars: {
    DateTime: {
      Input: Date;
      Output: Date;
    };
  };
}>({
  plugins: [ZodPlugin],
});
 
// Register a custom DateTime scalar
builder.scalarType("DateTime", {
  serialize: (value) => value.toISOString(),
  parseValue: (value) => {
    if (typeof value !== "string") {
      throw new Error("DateTime must be a string");
    }
    return new Date(value);
  },
});
 
// Initialize Query and Mutation types
builder.queryType({});
builder.mutationType({});

Notice how you pass GraphQLContext as a generic. This means every resolver will have access to currentUser and db with full type inference — no casting needed.


Step 5: Define the User Type

Create graphql/types/user.ts:

import { builder } from "../builder";
 
builder.objectType("User", {
  description: "A registered user",
  fields: (t) => ({
    id: t.exposeString("id"),
    name: t.exposeString("name"),
    email: t.exposeString("email"),
    bookmarks: t.field({
      type: ["Bookmark"],
      resolve: (user, _args, ctx) => {
        return ctx.db.bookmarks.findMany({ userId: user.id });
      },
    }),
  }),
});
 
// Query: get current user
builder.queryField("me", (t) =>
  t.field({
    type: "User",
    nullable: true,
    resolve: (_root, _args, ctx) => ctx.currentUser,
  })
);

The exposeString method is a Pothos shorthand that maps a field directly to an object property. For computed fields like bookmarks, you write a full resolver.


Step 6: Define the Bookmark Type with Queries

This is the main type. Create graphql/types/bookmark.ts:

import { builder } from "../builder";
import { z } from "zod";
 
// Define the Bookmark object type
const BookmarkType = builder.objectType("Bookmark", {
  description: "A saved bookmark with URL and metadata",
  fields: (t) => ({
    id: t.exposeString("id"),
    url: t.exposeString("url"),
    title: t.exposeString("title"),
    description: t.exposeString("description", { nullable: true }),
    tags: t.exposeStringList("tags"),
    createdAt: t.expose("createdAt", { type: "DateTime" }),
    updatedAt: t.expose("updatedAt", { type: "DateTime" }),
    user: t.field({
      type: "User",
      nullable: true,
      resolve: (bookmark, _args, ctx) => {
        return ctx.db.users.findById(bookmark.userId);
      },
    }),
  }),
});
 
// Query: list all bookmarks with optional filters
builder.queryField("bookmarks", (t) =>
  t.field({
    type: [BookmarkType],
    args: {
      tag: t.arg.string({ required: false }),
      search: t.arg.string({ required: false }),
    },
    resolve: (_root, args, ctx) => {
      return ctx.db.bookmarks.findMany({
        tag: args.tag ?? undefined,
        search: args.search ?? undefined,
      });
    },
  })
);
 
// Query: get a single bookmark by ID
builder.queryField("bookmark", (t) =>
  t.field({
    type: BookmarkType,
    nullable: true,
    args: {
      id: t.arg.string({ required: true }),
    },
    resolve: (_root, args, ctx) => {
      return ctx.db.bookmarks.findById(args.id);
    },
  })
);

The args definition gives you typed arguments. When the client sends bookmarks(tag: "graphql"), the args.tag value is properly typed as string | null | undefined.


Step 7: Add Mutations with Zod Validation

Now add create, update, and delete mutations to the same graphql/types/bookmark.ts file:

// Zod schemas for input validation
const CreateBookmarkInput = z.object({
  url: z.string().url("Must be a valid URL"),
  title: z.string().min(1, "Title is required").max(200),
  description: z.string().max(1000).nullable().optional(),
  tags: z.array(z.string().max(50)).max(10).default([]),
});
 
const UpdateBookmarkInput = z.object({
  url: z.string().url("Must be a valid URL").optional(),
  title: z.string().min(1).max(200).optional(),
  description: z.string().max(1000).nullable().optional(),
  tags: z.array(z.string().max(50)).max(10).optional(),
});
 
// Mutation: create a bookmark
builder.mutationField("createBookmark", (t) =>
  t.field({
    type: BookmarkType,
    args: {
      input: t.arg({
        type: builder.inputType("CreateBookmarkInput", {
          fields: (t) => ({
            url: t.string({ required: true }),
            title: t.string({ required: true }),
            description: t.string({ required: false }),
            tags: t.stringList({ required: false, defaultValue: [] }),
          }),
        }),
        required: true,
      }),
    },
    resolve: (_root, args, ctx) => {
      if (!ctx.currentUser) {
        throw new Error("You must be logged in to create a bookmark");
      }
 
      // Validate with Zod
      const validated = CreateBookmarkInput.parse({
        url: args.input.url,
        title: args.input.title,
        description: args.input.description,
        tags: args.input.tags,
      });
 
      return ctx.db.bookmarks.create({
        ...validated,
        description: validated.description ?? null,
        tags: validated.tags,
        userId: ctx.currentUser.id,
      });
    },
  })
);
 
// Mutation: update a bookmark
builder.mutationField("updateBookmark", (t) =>
  t.field({
    type: BookmarkType,
    nullable: true,
    args: {
      id: t.arg.string({ required: true }),
      input: t.arg({
        type: builder.inputType("UpdateBookmarkInput", {
          fields: (t) => ({
            url: t.string({ required: false }),
            title: t.string({ required: false }),
            description: t.string({ required: false }),
            tags: t.stringList({ required: false }),
          }),
        }),
        required: true,
      }),
    },
    resolve: (_root, args, ctx) => {
      if (!ctx.currentUser) {
        throw new Error("You must be logged in to update a bookmark");
      }
 
      const existing = ctx.db.bookmarks.findById(args.id);
      if (!existing || existing.userId !== ctx.currentUser.id) {
        throw new Error("Bookmark not found or not authorized");
      }
 
      const validated = UpdateBookmarkInput.parse({
        url: args.input.url,
        title: args.input.title,
        description: args.input.description,
        tags: args.input.tags,
      });
 
      return ctx.db.bookmarks.update(args.id, validated);
    },
  })
);
 
// Mutation: delete a bookmark
builder.mutationField("deleteBookmark", (t) =>
  t.field({
    type: "Boolean",
    args: {
      id: t.arg.string({ required: true }),
    },
    resolve: (_root, args, ctx) => {
      if (!ctx.currentUser) {
        throw new Error("You must be logged in to delete a bookmark");
      }
 
      const existing = ctx.db.bookmarks.findById(args.id);
      if (!existing || existing.userId !== ctx.currentUser.id) {
        throw new Error("Bookmark not found or not authorized");
      }
 
      return ctx.db.bookmarks.delete(args.id);
    },
  })
);

Every mutation checks ctx.currentUser before proceeding. This is the GraphQL equivalent of middleware — you control access at the resolver level.

The Zod schemas validate input at runtime. If a client sends an invalid URL or a title that is too long, they get a clear error message instead of corrupted data.


Step 8: Assemble the Schema

Create graphql/schema.ts to pull everything together:

import { builder } from "./builder";
 
// Import types to register them with the builder
import "./types/user";
import "./types/bookmark";
 
// Build and export the executable schema
export const schema = builder.toSchema();

The imports are side-effectful — they register types with the builder when the module loads. This is a common pattern in Pothos. The order does not matter because Pothos resolves references lazily.


Step 9: Create the GraphQL Route Handler

Now expose the schema as a Next.js API route. Create app/api/graphql/route.ts:

import { createYoga } from "graphql-yoga";
import { schema } from "@/graphql/schema";
import { createContext } from "@/graphql/context";
 
const yoga = createYoga({
  schema,
  context: ({ request }) => createContext(request),
  graphqlEndpoint: "/api/graphql",
 
  // Enable GraphiQL playground in development
  graphiql: process.env.NODE_ENV === "development",
 
  // CORS for development
  fetchAPI: { Response },
});
 
const handler = yoga;
 
export { handler as GET, handler as POST };

That is all you need. GraphQL Yoga handles:

  • Query parsing and validation against your schema
  • Execution with proper error handling
  • The GraphiQL playground for development
  • Both GET and POST methods (GET for queries via URL, POST for all operations)

Step 10: Test with GraphiQL

Start the development server:

npm run dev

Open http://localhost:3000/api/graphql in your browser. You should see the GraphiQL playground. Try these queries:

List all bookmarks:

query {
  bookmarks {
    id
    title
    url
    tags
    user {
      name
    }
  }
}

Search bookmarks:

query {
  bookmarks(search: "react") {
    title
    url
    description
  }
}

Filter by tag:

query {
  bookmarks(tag: "graphql") {
    title
    tags
  }
}

Get a single bookmark:

query {
  bookmark(id: "1") {
    title
    url
    description
    createdAt
    user {
      name
      email
    }
  }
}

For mutations, add the x-user-id header in the GraphiQL headers panel (bottom-left):

{
  "x-user-id": "1"
}

Then run:

mutation {
  createBookmark(input: {
    url: "https://yoga-graphql.com"
    title: "GraphQL Yoga"
    description: "A fully-featured GraphQL server"
    tags: ["graphql", "server"]
  }) {
    id
    title
    url
    tags
    createdAt
  }
}

Step 11: Set Up the urql Client

Now build a React frontend to consume the API. Create lib/urql.ts:

import { cacheExchange, createClient, fetchExchange } from "@urql/next";
 
export function makeClient() {
  return createClient({
    url: "/api/graphql",
    exchanges: [cacheExchange, fetchExchange],
    fetchOptions: () => ({
      headers: {
        // In production, use a real auth token
        "x-user-id": "1",
      },
    }),
  });
}

Create a provider component at app/providers.tsx:

"use client";
 
import { UrqlProvider, ssrExchange } from "@urql/next";
import { useMemo } from "react";
import { makeClient } from "@/lib/urql";
 
export function Providers({ children }: { children: React.ReactNode }) {
  const [client, ssr] = useMemo(() => {
    const ssr = ssrExchange({ isClient: typeof window !== "undefined" });
    const client = makeClient();
    return [client, ssr];
  }, []);
 
  return (
    <UrqlProvider client={client} ssr={ssr}>
      {children}
    </UrqlProvider>
  );
}

Wrap your root layout with the provider in app/layout.tsx:

import { Providers } from "./providers";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Step 12: Build the Bookmarks Page

Create app/bookmarks/page.tsx:

"use client";
 
import { useQuery, useMutation } from "@urql/next";
import { gql } from "graphql-tag";
import { useState } from "react";
 
const BOOKMARKS_QUERY = gql`
  query Bookmarks($search: String, $tag: String) {
    bookmarks(search: $search, tag: $tag) {
      id
      title
      url
      description
      tags
      createdAt
      user {
        name
      }
    }
  }
`;
 
const CREATE_BOOKMARK = gql`
  mutation CreateBookmark($input: CreateBookmarkInput!) {
    createBookmark(input: $input) {
      id
      title
      url
      tags
    }
  }
`;
 
const DELETE_BOOKMARK = gql`
  mutation DeleteBookmark($id: String!) {
    deleteBookmark(id: $id)
  }
`;
 
export default function BookmarksPage() {
  const [search, setSearch] = useState("");
  const [selectedTag, setSelectedTag] = useState("");
 
  const [result, reexecute] = useQuery({
    query: BOOKMARKS_QUERY,
    variables: {
      search: search || null,
      tag: selectedTag || null,
    },
  });
 
  const [, createBookmark] = useMutation(CREATE_BOOKMARK);
  const [, deleteBookmark] = useMutation(DELETE_BOOKMARK);
 
  const { data, fetching, error } = result;
 
  async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = new FormData(e.currentTarget);
 
    await createBookmark({
      input: {
        url: form.get("url") as string,
        title: form.get("title") as string,
        description: (form.get("description") as string) || null,
        tags: (form.get("tags") as string)
          .split(",")
          .map((t) => t.trim())
          .filter(Boolean),
      },
    });
 
    e.currentTarget.reset();
    reexecute({ requestPolicy: "network-only" });
  }
 
  async function handleDelete(id: string) {
    await deleteBookmark({ id });
    reexecute({ requestPolicy: "network-only" });
  }
 
  // Collect all unique tags for the filter
  const allTags = [
    ...new Set(data?.bookmarks?.flatMap((b: any) => b.tags) ?? []),
  ];
 
  return (
    <div className="max-w-4xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">Bookmarks</h1>
 
      {/* Search and filter */}
      <div className="flex gap-4 mb-6">
        <input
          type="text"
          placeholder="Search bookmarks..."
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          className="flex-1 px-4 py-2 border rounded-lg"
        />
        <select
          value={selectedTag}
          onChange={(e) => setSelectedTag(e.target.value)}
          className="px-4 py-2 border rounded-lg"
        >
          <option value="">All tags</option>
          {allTags.map((tag) => (
            <option key={tag} value={tag}>
              {tag}
            </option>
          ))}
        </select>
      </div>
 
      {/* Create form */}
      <form onSubmit={handleCreate} className="mb-8 p-4 bg-gray-50 rounded-lg">
        <h2 className="text-lg font-semibold mb-4">Add Bookmark</h2>
        <div className="grid grid-cols-2 gap-4">
          <input name="url" placeholder="URL" required className="px-3 py-2 border rounded" />
          <input name="title" placeholder="Title" required className="px-3 py-2 border rounded" />
          <input name="description" placeholder="Description (optional)" className="px-3 py-2 border rounded" />
          <input name="tags" placeholder="Tags (comma-separated)" className="px-3 py-2 border rounded" />
        </div>
        <button type="submit" className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
          Add Bookmark
        </button>
      </form>
 
      {/* Bookmarks list */}
      {fetching && <p>Loading...</p>}
      {error && <p className="text-red-500">Error: {error.message}</p>}
      {data?.bookmarks && (
        <div className="space-y-4">
          {data.bookmarks.map((bookmark: any) => (
            <div key={bookmark.id} className="p-4 border rounded-lg hover:shadow-md transition-shadow">
              <div className="flex justify-between items-start">
                <div>
                  <a
                    href={bookmark.url}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="text-lg font-semibold text-blue-600 hover:underline"
                  >
                    {bookmark.title}
                  </a>
                  <p className="text-sm text-gray-500 mt-1">{bookmark.url}</p>
                  {bookmark.description && (
                    <p className="text-gray-700 mt-2">{bookmark.description}</p>
                  )}
                  <div className="flex gap-2 mt-2">
                    {bookmark.tags.map((tag: string) => (
                      <span
                        key={tag}
                        className="px-2 py-1 bg-gray-200 rounded-full text-xs cursor-pointer hover:bg-gray-300"
                        onClick={() => setSelectedTag(tag)}
                      >
                        {tag}
                      </span>
                    ))}
                  </div>
                  <p className="text-xs text-gray-400 mt-2">
                    by {bookmark.user?.name} &middot; {new Date(bookmark.createdAt).toLocaleDateString()}
                  </p>
                </div>
                <button
                  onClick={() => handleDelete(bookmark.id)}
                  className="text-red-500 hover:text-red-700 text-sm"
                >
                  Delete
                </button>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Visit http://localhost:3000/bookmarks to see the full CRUD interface. You can search, filter by tags, create new bookmarks, and delete existing ones — all powered by your GraphQL API.


Step 13: Add Error Handling

GraphQL has a built-in error system. When a resolver throws, Yoga wraps it in a proper GraphQL error response. But you can make errors more structured.

Create graphql/errors.ts:

import { GraphQLError } from "graphql";
 
export class AuthenticationError extends GraphQLError {
  constructor(message = "You must be logged in") {
    super(message, {
      extensions: { code: "UNAUTHENTICATED" },
    });
  }
}
 
export class AuthorizationError extends GraphQLError {
  constructor(message = "You are not authorized to perform this action") {
    super(message, {
      extensions: { code: "FORBIDDEN" },
    });
  }
}
 
export class NotFoundError extends GraphQLError {
  constructor(resource: string) {
    super(`${resource} not found`, {
      extensions: { code: "NOT_FOUND" },
    });
  }
}

Now update your mutations to use these errors instead of generic throw new Error():

import { AuthenticationError, NotFoundError } from "../errors";
 
// In createBookmark resolver:
if (!ctx.currentUser) {
  throw new AuthenticationError();
}
 
// In updateBookmark resolver:
const existing = ctx.db.bookmarks.findById(args.id);
if (!existing) {
  throw new NotFoundError("Bookmark");
}

The extensions.code field lets the client handle errors programmatically:

if (error.graphQLErrors.some((e) => e.extensions?.code === "UNAUTHENTICATED")) {
  // Redirect to login
}

Step 14: Add Pagination

For production APIs, you need pagination. Add cursor-based pagination to the bookmarks query. Update graphql/types/bookmark.ts:

builder.queryField("paginatedBookmarks", (t) =>
  t.field({
    type: builder.objectType("BookmarkConnection", {
      fields: (t) => ({
        edges: t.field({
          type: [
            builder.objectType("BookmarkEdge", {
              fields: (t) => ({
                cursor: t.exposeString("cursor"),
                node: t.field({
                  type: BookmarkType,
                  resolve: (edge) => edge.node,
                }),
              }),
            }),
          ],
          resolve: (connection) => connection.edges,
        }),
        pageInfo: t.field({
          type: builder.objectType("PageInfo", {
            fields: (t) => ({
              hasNextPage: t.exposeBoolean("hasNextPage"),
              endCursor: t.exposeString("endCursor", { nullable: true }),
            }),
          }),
          resolve: (connection) => connection.pageInfo,
        }),
        totalCount: t.exposeInt("totalCount"),
      }),
    }),
    args: {
      first: t.arg.int({ required: false, defaultValue: 10 }),
      after: t.arg.string({ required: false }),
    },
    resolve: (_root, args, ctx) => {
      const all = ctx.db.bookmarks.findMany();
      const first = Math.min(args.first ?? 10, 50);
 
      let startIndex = 0;
      if (args.after) {
        const afterIndex = all.findIndex((b) => b.id === args.after);
        if (afterIndex !== -1) startIndex = afterIndex + 1;
      }
 
      const slice = all.slice(startIndex, startIndex + first);
      const hasNextPage = startIndex + first < all.length;
 
      return {
        edges: slice.map((node) => ({ cursor: node.id, node })),
        pageInfo: {
          hasNextPage,
          endCursor: slice.length > 0 ? slice[slice.length - 1].id : null,
        },
        totalCount: all.length,
      };
    },
  })
);

Query it with:

query {
  paginatedBookmarks(first: 2) {
    edges {
      cursor
      node {
        title
        url
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}

For the next page, pass after: "last-cursor-value".


Troubleshooting

"Cannot find module @/graphql/schema"

Make sure your tsconfig.json has the @/* path alias configured:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"]
    }
  }
}

GraphiQL not loading

GraphiQL is only enabled in development mode. Check that NODE_ENV is not set to production in your .env file.

"You must be logged in" errors

For mutations, add the x-user-id: 1 header in GraphiQL (headers panel at the bottom) or in your urql client configuration.

Type errors in resolvers

If TypeScript complains about resolver return types, check that your db functions return types matching the Pothos object type fields. Pothos infers types from the builder generics, so a mismatch between your data shape and the type definition causes errors.

Hot reload not picking up schema changes

GraphQL Yoga caches the schema. Restart the dev server if structural changes (new types, renamed fields) are not reflected.


Next Steps

You now have a fully type-safe GraphQL API with Next.js. Here are some ways to extend it:

  • Add a real database — Replace the in-memory store with Prisma or Drizzle ORM connected to PostgreSQL
  • Implement authentication — Use NextAuth.js or Better Auth to issue real tokens
  • Add subscriptions — GraphQL Yoga supports WebSocket subscriptions for real-time updates
  • Generate client types — While Pothos handles server types, you can use graphql-codegen to generate typed hooks for your frontend
  • Deploy — GraphQL Yoga works on Vercel, Cloudflare Workers, and any Node.js platform

Conclusion

In this tutorial, you built a complete GraphQL API using Next.js App Router, GraphQL Yoga, and Pothos. You learned how to:

  1. Define a code-first schema with full TypeScript inference using Pothos
  2. Set up GraphQL Yoga as a Next.js route handler
  3. Build queries with filtering and mutations with Zod validation
  4. Implement authentication via context and structured error handling
  5. Add cursor-based pagination for production use
  6. Connect a React frontend with urql for type-safe data fetching

The combination of Pothos and GraphQL Yoga gives you the best of both worlds: the flexibility of GraphQL with the type safety of TypeScript — no code generation, no SDL files, just TypeScript all the way down.


Want to read more tutorials? Check out our latest tutorial on Integrating Moyasar Web Payment Using Credit Cards.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build End-to-End Type-Safe APIs with tRPC and Next.js App Router

Learn how to build fully type-safe APIs with tRPC and Next.js 15 App Router. This hands-on tutorial covers router setup, procedures, middleware, React Query integration, and server-side calls — all without writing a single API schema.

28 min read·