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

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-appSelect 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-tagHere is what each package does:
| Package | Purpose |
|---|---|
graphql | The GraphQL reference implementation |
graphql-yoga | Lightweight, spec-compliant GraphQL server |
@pothos/core | Code-first schema builder with type inference |
@pothos/plugin-zod | Zod validation integration for Pothos |
zod | Runtime schema validation |
urql | Lightweight GraphQL client for React |
@urql/next | Next.js-specific urql bindings |
graphql-tag | Tagged 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 devOpen 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} · {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-codegento 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:
- Define a code-first schema with full TypeScript inference using Pothos
- Set up GraphQL Yoga as a Next.js route handler
- Build queries with filtering and mutations with Zod validation
- Implement authentication via context and structured error handling
- Add cursor-based pagination for production use
- 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.
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.

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.