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.
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.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report 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.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.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 nowJavaScript’s Date API has many limitations. Explore alternative libraries like Moment.js, date-fns, and the new Temporal API.
Explore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.
Build a real-time image background remover in Vue using Transformers.js and WebGPU for client-side processing with privacy and efficiency.