Editor’s Note: This post was updated on 14 May 2021 to reflect updated information and new code using TypeScript to add type safety and with DataLoader to avoid N+1s, using Prisma instead of Knex for the database layer, and incorporating a code-first GraphQL schema approach.
Many people only think of Next.js as a frontend React framework, providing server-side rendering, built-in routing, and a number of performance features. All of this is true, but Next.js also supports serverless functions via its API routes, which is an easy way to provide a backend to your React frontend code, all within the same application.
In this article, we will learn how to use API routes to set up a GraphQL API within Next.js. We will work with a fully typed setup: TypeScript, loading data from Postgres with Prisma, and using a code-first GraphQL Schema with Nexus.
We will also learn how to improve performance using the DataLoader package and pattern, avoiding costly N+1 queries.Full source code can be found here.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
At the end of this article, we want to be able to perform the following GraphQL query of albums and artists, loaded efficiently (with only two SQL queries) from our Postgres database:
{
albums(first: 5) {
id
name
year
artist {
id
name
}
}
}
Producing output which might resemble:
{
"data": {
"albums": [
{
"id": 3,
"name": "Rotation & Frequency",
"year": "2020",
"artist": {
"id": 2,
"name": "Slick Shoes"
}
},
{
"id": 5,
"name": "I Am",
"year": "2018",
"artist": {
"id": 3,
"name": "Sleeping Giant"
}
}
// etc...
]
}
}
The easiest way to set up Next.js is to run the command yarn create next-app. As soon as you change one of the files in Next.js to a TypeScript extension (.ts or .tsx) and run yarn dev, it will ask you to install a few additional packages and will create a tsconfig.json file automatically.
Feel free to avoid this step, but we’re going to modify the tsconfig.json file slightly to make TypeScript enforce stricter rules:
// tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": ["dom", "esnext"],
"incremental": true,
"allowJs": false,
"checkJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"jsx": "preserve",
"allowSyntheticDefaultImports": true,
/* Module Resolution */
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
/* Type Checking */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", ".next"]
}
For a full list of the packages used in this article, please refer to the package.json file.
Prisma will be the tool we use to load data from our Postgres database. To get started, run the command npx prisma init, which will generate a prisma folder that contains a schema.prisma file.
Note that it references a DATABASE_URL environment variable. Ensure that this is available in via a .env file (but don’t commit it!). We will modify the schema file to include two models: Album and Artist.
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Album {
id Int @id @default(autoincrement())
name String
year String
artist Artist @relation(fields: [artistId], references: [id])
artistId Int
}
model Artist {
id Int @id @default(autoincrement())
name String
url String
albums Album[]
}
In order to create the Album and Artist tables in our database, we’ll need to tell Prisma to create and run a database migration:npx prisma migrate dev --name init.
When developing locally, it’s good to have some consistent seed data to work with. Prisma supports database seeding, but first let’s update the scripts section in our package.json file to have a ts-node script, required to execute the Prisma seed command.
// package.json
{
"scripts": {
"ts-node": "ts-node --compiler-options '{\"module\":\"CommonJS\"}'"
}
}
Next we’ll create a seed.ts file in the prisma folder. Its purpose is to generate any data you’ll want to use locally when developing. Feel free to put your favourite bands in here!
// prisma/seed.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
const comeback = await prisma.artist.create({
data: {
name: "Comeback Kid",
url: "https://comeback-kid.com/",
albums: {
create: [
{ name: "Turn It Around", year: "2003" },
{ name: "Wake the Dead", year: "2005" },
],
},
},
});
console.log({ comeback });
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Once you’re ready, run npx prisma db seed --preview-feature to execute the seed file and populate our local database.
With Next.js set up, we’re going to add an API (server) route to our app. This is as easy as creating a file within the pages/api folder called graphql.ts. For now, its contents will be:
// pages/api/graphql.ts
export default (_req, res) => res.end("GraphQL!");
Done! Isn’t GraphQL easy? Just joking… The above code will simply respond with the text “GraphQL!”, but with this setup we could respond with any JSON that we wanted, reading query params, headers, etc. from the req (request) object.
Time for the actual GraphQL handling. For this, we’ll use ApolloServer (from the apollo-server-micro package — this one works great in Next.js). You’ll notice that we import schema and context from two files. These don’t exist yet but are where we’ll spend the bulk of our time later on.
// pages/api/graphql.ts
import { ApolloServer } from "apollo-server-micro";
import { schema } from "../../src/schema";
import { context } from "../../src/context";
const server = new ApolloServer({ schema, context });
const handler = server.createHandler({ path: "/api/graphql" });
export default handler;
Every resolver in GraphQL receives something called the context. It is a place to put global things such as:
We don’t have authenticated users in this example, but we do have the other two. We’ll start by exporting a context function that returns an instance of the Prisma client.
// src/context.ts
import { PrismaClient } from "@prisma/client";
import { Artist } from ".prisma/client";
const prisma = new PrismaClient({ log: ["query"] });
export interface Context {
prisma: PrismaClient;
}
export function context(): Context {
return { prisma };
}
With our context in place, it’s time to generate our GraphQL schema. Often you will find handwritten schema.graphql files, along with corresponding resolvers to load the data to be returned from each field.
This is not the case with Nexus. Nexus takes a code-first approach where the schema.graphql file is a generated artifact from the GraphQL type definition. This means you don’t have to keep them in sync!
We will create three GraphQL types: Query, Artist, and Album. The key sections of this makeSchema call are:
outputs: Where do you want the schema.graphql and TypeScript types output to? These are the artifacts I mentioned abovesourceTypes: If you have pre-existing TypeScript types that align with your GraphQL types, you can avoid recreating them! We’ll be pointing it to the types created from our Prisma ClientcontextType: This allows us to tell Nexus where to look for the TypeScript type of our contextnonNullDefaults: We are going to define each of our GraphQL fields as non-null by default… this can be overridden but I feel like it’s better to start with strict code by default// src/schema.ts
import { makeSchema, objectType, queryType } from "nexus";
import { join } from "path";
export const schema = makeSchema({
types: [Query, Artist, Album],
shouldGenerateArtifacts: process.env.NODE_ENV === "development",
outputs: {
schema: join(process.cwd(), "schema.graphql"),
typegen: join(process.cwd(), "nexus.ts"),
},
sourceTypes: {
modules: [{ module: ".prisma/client", alias: "prisma" }],
debug: process.env.NODE_ENV === "development",
},
contextType: {
module: join(process.cwd(), "src", "context.ts"),
export: "Context",
},
nonNullDefaults: {
input: true,
output: true,
},
});
The Query type (along with Mutation and Subscription, which we won’t be covering here) are the top level fields of your GraphQL API. They are the entry points into your API. On our Query type, we will define one field: albums, which will use the Prisma client from our context to load the albums.
// src/schema.ts
const Query = queryType({
definition(t) {
t.list.field("albums", {
type: "Album",
args: {
first: "Int",
},
resolve(_root, args, ctx) {
return ctx.prisma.album.findMany({ take: args.first });
},
});
},
});
Album and Artist TypesThe types for Artist and Album are created using the objectType function from Nexus. Most of the fields can be left without explicit resolvers because they are simply accessing attributes without any manipulation.
The Album type has the ability to load an Artist though, and we’ll create a custom resolver to load the artist from the database.
// src/schema.ts
const Artist = objectType({
name: "Artist",
definition(t) {
t.int("id");
t.string("name");
t.string("url");
},
});
const Album = objectType({
name: "Album",
definition(t) {
t.int("id");
t.string("name");
t.string("year");
t.field("artist", {
type: "Artist",
async resolve(album, _args, ctx) {
const artist = await ctx.prisma.artist.findFirst({
where: { id: album.artistId },
});
// The ! tells TypeScript to trust us, it won't be null
return artist!;
},
});
},
});
There is a hidden problem with the above resolvers. Specifically, loading the artist for each album. An SQL query gets run for each album, meaning that if you have 50 albums to display, you will have to perform 50 additional SQL queries to load each album’s artist. Marc-André Giroux has a great article on this problem, and we’re going to discover how to solve it right now!
The first step is to define a loader function. The purpose of a loader is to pool up IDs (of artists in our case) and load them all at once in a single batch, rather than each one on its own. You’re lazy-loading the data.
// src/context.ts
const createArtistLoader = () =>
new DataLoader<number, Artist | null>(async (ids) => {
// Load all of the artists for the given `ids`
const artists = await prisma.artist.findMany({
where: { id: { in: [...ids] } },
});
// To make for more efficient lookup, convert to a Map of id => Artist records
const artistMap = artists.reduce(
(acc, artist) => acc.set(artist.id, artist),
new Map<number, Artist>()
);
// Map the ids back to the loaded Artist record
return ids.map((id) => artistMap.get(id) ?? null);
});
We’ll need to add this DataLoader instance to our Context with the following changes:
// src/context.ts
export interface Context {
prisma: PrismaClient;
artistLoader: ReturnType;
}
export function createContext(): Context {
return { prisma, artistLoader: createArtistLoader() };
}
This allows us to update our Album type to utilize the DataLoader inside of the artist resolver:
const Album = objectType({
name: "Album",
definition(t) {
t.int("id");
t.string("name");
t.string("year");
t.field("artist", {
type: "Artist",
async resolve(album, _args, ctx) {
const artist = await ctx.artistLoader.load(album.artistId);
return artist!;
},
});
},
});
The end result is a single query to the database to load all the artists at once… N+1 problem solved! We should now see two queries total: One to load the albums, and another to load all of the artists.
In this article we were able to create a typed, code-first GraphQL server in Next.js, loading data from Postgres with Prisma, and stomping out N+1 performance issues using DataLoader. Not bad for a day’s work!
The next step might involve adding mutations along with authentication to our app, enabling users to create and modify data with the correct permissions. Next.js is no longer just for the frontend as you can see. It has first-class support for serverless endpoints and the perfect place to put your GraphQL API.
While GraphQL has some features for debugging requests and responses, making sure GraphQL reliably serves resources to your production app is where things get tougher. If you’re interested in ensuring network requests to the backend or third party services are successful, try LogRocket.
LogRocket lets you replay user sessions, eliminating guesswork around why bugs happen by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings — compatible with all frameworks.
LogRocket's Galileo AI watches sessions for you, instantly aggregating and reporting on problematic GraphQL requests to quickly understand the root cause. In addition, you can track Apollo client state and inspect GraphQL queries' key-value pairs.

:has(), with examplesThe CSS :has() pseudo-class is a powerful new feature that lets you style parents, siblings, and more – writing cleaner, more dynamic CSS with less JavaScript.

Kombai AI converts Figma designs into clean, responsive frontend code. It helps developers build production-ready UIs faster while keeping design accuracy and code quality intact.

Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the October 22nd issue.

John Reilly discusses how software development has been changed by the innovations of AI: both the positives and the negatives.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now