Back to blog

Building Type-Safe GraphQL APIs with TypeScript and Next.js

13 min readBy Mustafa Akkaya
#GraphQL#TypeScript#Next.js#Apollo#API Development

GraphQL revolutionizes API development by giving clients precise control over data fetching. Combined with TypeScript and Next.js, you get end-to-end type safety from database to UI. Let's build a production-ready GraphQL API.

Why GraphQL?

GraphQL solves REST API limitations:

- No Over-fetching - Request exactly what you need

- No Under-fetching - Get related data in one request

- Strong Typing - Schema-first design with built-in validation

- Self-Documenting - Introspection and GraphQL Playground

- Real-time - Built-in subscriptions for live data

- Versionless - Evolve API without breaking changes

Project Setup

Initialize a Next.js project with GraphQL support.

bash

# Create Next.js app

npx create-next-app@latest graphql-api --typescript --tailwind --app

cd graphql-api

# Install GraphQL dependencies

npm install graphql graphql-tag @apollo/server @as-integrations/next

npm install @graphql-tools/schema @graphql-codegen/cli

npm install -D @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

npm install -D @graphql-codegen/typescript-operations

# Database & validation

npm install prisma @prisma/client zod

npm install -D prisma

Schema Design

Define your GraphQL schema with strong types.

graphql

# graphql/schema.graphql

type User {

id: ID!

email: String!

name: String!

role: UserRole!

posts: [Post!]!

createdAt: DateTime!

updatedAt: DateTime!

}

enum UserRole {

ADMIN

USER

MODERATOR

}

type Post {

id: ID!

title: String!

content: String!

published: Boolean!

author: User!

authorId: String!

tags: [String!]!

views: Int!

createdAt: DateTime!

updatedAt: DateTime!

}

type Query {

# User queries

me: User

user(id: ID!): User

users(skip: Int, take: Int, orderBy: UserOrderBy): UsersResponse!

# Post queries

post(id: ID!): Post

posts(

skip: Int

take: Int

published: Boolean

search: String

): PostsResponse!

}

type Mutation {

# Auth mutations

signup(input: SignupInput!): AuthPayload!

login(input: LoginInput!): AuthPayload!

# User mutations

updateUser(id: ID!, input: UpdateUserInput!): User!

deleteUser(id: ID!): Boolean!

# Post mutations

createPost(input: CreatePostInput!): Post!

updatePost(id: ID!, input: UpdatePostInput!): Post!

deletePost(id: ID!): Boolean!

publishPost(id: ID!): Post!

}

type Subscription {

postCreated: Post!

postUpdated(id: ID!): Post!

}

# Input types

input SignupInput {

email: String!

password: String!

name: String!

}

input LoginInput {

email: String!

password: String!

}

input CreatePostInput {

title: String!

content: String!

tags: [String!]

published: Boolean

}

input UpdatePostInput {

title: String

content: String

tags: [String!]

published: Boolean

}

input UpdateUserInput {

name: String

email: String

role: UserRole

}

enum UserOrderBy {

createdAt_ASC

createdAt_DESC

name_ASC

name_DESC

}

# Response types

type UsersResponse {

users: [User!]!

total: Int!

hasMore: Boolean!

}

type PostsResponse {

posts: [Post!]!

total: Int!

hasMore: Boolean!

}

type AuthPayload {

token: String!

user: User!

}

# Custom scalars

scalar DateTime

Code Generation

Automatically generate TypeScript types from schema.

typescript

// codegen.ts

import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {

schema: "./graphql/schema.graphql",

generates: {

"./graphql/generated/types.ts": {

plugins: ["typescript", "typescript-resolvers"],

config: {

useIndexSignature: true,

contextType: "../context#Context",

mappers: {

User: "@prisma/client#User",

Post: "@prisma/client#Post",

},

},

},

},

};

export default config;

json

// package.json scripts

{

"scripts": {

"codegen": "graphql-codegen --config codegen.ts",

"codegen:watch": "graphql-codegen --config codegen.ts --watch",

"prisma:generate": "prisma generate",

"dev": "npm run codegen && next dev"

}

}

Prisma Schema

Set up database models matching GraphQL schema.

prisma

// prisma/schema.prisma

generator client {

provider = "prisma-client-js"

}

datasource db {

provider = "postgresql"

url = env("DATABASE_URL")

}

enum UserRole {

ADMIN

USER

MODERATOR

}

model User {

id String @id @default(cuid())

email String @unique

password String

name String

role UserRole @default(USER)

posts Post[]

createdAt DateTime @default(now())

updatedAt DateTime @updatedAt

}

model Post {

id String @id @default(cuid())

title String

content String

published Boolean @default(false)

author User @relation(fields: [authorId], references: [id], onDelete: Cascade)

authorId String

tags String[]

views Int @default(0)

createdAt DateTime @default(now())

updatedAt DateTime @updatedAt

@@index([authorId])

@@index([published])

}

Context Setup

Create resolver context with authentication.

typescript

// graphql/context.ts

import { PrismaClient } from "@prisma/client";

import { NextRequest } from "next/server";

import jwt from "jsonwebtoken";

const prisma = new PrismaClient();

export interface Context {

prisma: PrismaClient;

user: {

id: string;

email: string;

role: string;

} | null;

}

export async function createContext(req: NextRequest): Promise {

const token = req.headers.get("authorization")?.replace("Bearer ", "");

let user = null;

if (token) {

try {

const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;

user = {

id: decoded.userId,

email: decoded.email,

role: decoded.role,

};

} catch (error) {

console.error("Invalid token:", error);

}

}

return {

prisma,

user,

};

}

Resolvers Implementation

Implement type-safe resolvers.

typescript

// graphql/resolvers/user.ts

import { Resolvers } from "../generated/types";

import { GraphQLError } from "graphql";

import bcrypt from "bcryptjs";

import jwt from "jsonwebtoken";

export const userResolvers: Resolvers = {

Query: {

me: async (_, __, { user, prisma }) => {

if (!user) {

throw new GraphQLError("Not authenticated", {

extensions: { code: "UNAUTHENTICATED" },

});

}

return await prisma.user.findUnique({

where: { id: user.id },

});

},

user: async (_, { id }, { prisma }) => {

const foundUser = await prisma.user.findUnique({

where: { id },

});

if (!foundUser) {

throw new GraphQLError("User not found", {

extensions: { code: "NOT_FOUND" },

});

}

return foundUser;

},

users: async (_, { skip = 0, take = 10, orderBy }, { prisma }) => {

const [users, total] = await Promise.all([

prisma.user.findMany({

skip,

take,

orderBy: orderBy

? {

[orderBy.split("_")[0]]: orderBy.split("_")[1].toLowerCase(),

}

: undefined,

}),

prisma.user.count(),

]);

return {

users,

total,

hasMore: skip + take < total,

};

},

},

Mutation: {

signup: async (_, { input }, { prisma }) => {

const existingUser = await prisma.user.findUnique({

where: { email: input.email },

});

if (existingUser) {

throw new GraphQLError("Email already exists", {

extensions: { code: "BAD_USER_INPUT" },

});

}

const hashedPassword = await bcrypt.hash(input.password, 10);

const user = await prisma.user.create({

data: {

email: input.email,

name: input.name,

password: hashedPassword,

},

});

const token = jwt.sign(

{ userId: user.id, email: user.email, role: user.role },

process.env.JWT_SECRET!,

{ expiresIn: "7d" }

);

return { token, user };

},

login: async (_, { input }, { prisma }) => {

const user = await prisma.user.findUnique({

where: { email: input.email },

});

if (!user) {

throw new GraphQLError("Invalid credentials", {

extensions: { code: "UNAUTHENTICATED" },

});

}

const valid = await bcrypt.compare(input.password, user.password);

if (!valid) {

throw new GraphQLError("Invalid credentials", {

extensions: { code: "UNAUTHENTICATED" },

});

}

const token = jwt.sign(

{ userId: user.id, email: user.email, role: user.role },

process.env.JWT_SECRET!,

{ expiresIn: "7d" }

);

return { token, user };

},

updateUser: async (_, { id, input }, { user, prisma }) => {

if (!user) {

throw new GraphQLError("Not authenticated", {

extensions: { code: "UNAUTHENTICATED" },

});

}

if (user.id !== id && user.role !== "ADMIN") {

throw new GraphQLError("Not authorized", {

extensions: { code: "FORBIDDEN" },

});

}

return await prisma.user.update({

where: { id },

data: input,

});

},

deleteUser: async (_, { id }, { user, prisma }) => {

if (!user || user.role !== "ADMIN") {

throw new GraphQLError("Not authorized", {

extensions: { code: "FORBIDDEN" },

});

}

await prisma.user.delete({ where: { id } });

return true;

},

},

User: {

posts: async (parent, _, { prisma }) => {

return await prisma.post.findMany({

where: { authorId: parent.id },

});

},

},

};

// graphql/resolvers/post.ts

import { Resolvers } from "../generated/types";

import { GraphQLError } from "graphql";

export const postResolvers: Resolvers = {

Query: {

post: async (_, { id }, { prisma }) => {

const post = await prisma.post.findUnique({

where: { id },

include: { author: true },

});

if (!post) {

throw new GraphQLError("Post not found", {

extensions: { code: "NOT_FOUND" },

});

}

// Increment views

await prisma.post.update({

where: { id },

data: { views: { increment: 1 } },

});

return post;

},

posts: async (

_,

{ skip = 0, take = 10, published, search },

{ prisma }

) => {

const where = {

...(published !== undefined && { published }),

...(search && {

OR: [

{ title: { contains: search, mode: "insensitive" as const } },

{ content: { contains: search, mode: "insensitive" as const } },

],

}),

};

const [posts, total] = await Promise.all([

prisma.post.findMany({

where,

skip,

take,

orderBy: { createdAt: "desc" },

include: { author: true },

}),

prisma.post.count({ where }),

]);

return {

posts,

total,

hasMore: skip + take < total,

};

},

},

Mutation: {

createPost: async (_, { input }, { user, prisma }) => {

if (!user) {

throw new GraphQLError("Not authenticated", {

extensions: { code: "UNAUTHENTICATED" },

});

}

return await prisma.post.create({

data: {

...input,

authorId: user.id,

},

include: { author: true },

});

},

updatePost: async (_, { id, input }, { user, prisma }) => {

if (!user) {

throw new GraphQLError("Not authenticated", {

extensions: { code: "UNAUTHENTICATED" },

});

}

const post = await prisma.post.findUnique({ where: { id } });

if (!post) {

throw new GraphQLError("Post not found", {

extensions: { code: "NOT_FOUND" },

});

}

if (post.authorId !== user.id && user.role !== "ADMIN") {

throw new GraphQLError("Not authorized", {

extensions: { code: "FORBIDDEN" },

});

}

return await prisma.post.update({

where: { id },

data: input,

include: { author: true },

});

},

deletePost: async (_, { id }, { user, prisma }) => {

if (!user) {

throw new GraphQLError("Not authenticated", {

extensions: { code: "UNAUTHENTICATED" },

});

}

const post = await prisma.post.findUnique({ where: { id } });

if (!post) {

throw new GraphQLError("Post not found", {

extensions: { code: "NOT_FOUND" },

});

}

if (post.authorId !== user.id && user.role !== "ADMIN") {

throw new GraphQLError("Not authorized", {

extensions: { code: "FORBIDDEN" },

});

}

await prisma.post.delete({ where: { id } });

return true;

},

publishPost: async (_, { id }, { user, prisma }) => {

if (!user) {

throw new GraphQLError("Not authenticated", {

extensions: { code: "UNAUTHENTICATED" },

});

}

const post = await prisma.post.findUnique({ where: { id } });

if (!post) {

throw new GraphQLError("Post not found", {

extensions: { code: "NOT_FOUND" },

});

}

if (post.authorId !== user.id && user.role !== "ADMIN") {

throw new GraphQLError("Not authorized", {

extensions: { code: "FORBIDDEN" },

});

}

return await prisma.post.update({

where: { id },

data: { published: true },

include: { author: true },

});

},

},

Post: {

author: async (parent, _, { prisma }) => {

return await prisma.user.findUnique({

where: { id: parent.authorId },

});

},

},

};

Apollo Server Integration

Set up Apollo Server in Next.js App Router.

typescript

// app/api/graphql/route.ts

import { ApolloServer } from "@apollo/server";

import { startServerAndCreateNextHandler } from "@as-integrations/next";

import { makeExecutableSchema } from "@graphql-tools/schema";

import { readFileSync } from "fs";

import { join } from "path";

import { userResolvers } from "@/graphql/resolvers/user";

import { postResolvers } from "@/graphql/resolvers/post";

import { createContext } from "@/graphql/context";

import { NextRequest } from "next/server";

// Load schema

const typeDefs = readFileSync(

join(process.cwd(), "graphql/schema.graphql"),

"utf-8"

);

// Merge resolvers

const resolvers = {

Query: {

...userResolvers.Query,

...postResolvers.Query,

},

Mutation: {

...userResolvers.Mutation,

...postResolvers.Mutation,

},

User: userResolvers.User,

Post: postResolvers.Post,

};

// Create executable schema

const schema = makeExecutableSchema({

typeDefs,

resolvers,

});

// Create Apollo Server

const server = new ApolloServer({

schema,

introspection: process.env.NODE_ENV !== "production",

});

// Create Next.js handler

const handler = startServerAndCreateNextHandler(server, {

context: createContext,

});

export async function GET(request: NextRequest) {

return handler(request);

}

export async function POST(request: NextRequest) {

return handler(request);

}

Client-Side Usage

Use GraphQL in Next.js components.

typescript

// app/posts/page.tsx

"use client";

import { useEffect, useState } from "react";

const GET_POSTS =

query GetPosts($skip: Int, $take: Int, $published: Boolean) {

posts(skip: $skip, take: $take, published: $published) {

posts {

id

title

content

views

createdAt

author {

name

}

}

total

hasMore

}

}

;

export default function PostsPage() {

const [posts, setPosts] = useState([]);

const [loading, setLoading] = useState(true);

useEffect(() => {

async function fetchPosts() {

const response = await fetch("/api/graphql", {

method: "POST",

headers: {

"Content-Type": "application/json",

},

body: JSON.stringify({

query: GET_POSTS,

variables: {

skip: 0,

take: 10,

published: true,

},

}),

});

const { data } = await response.json();

setPosts(data.posts.posts);

setLoading(false);

}

fetchPosts();

}, []);

if (loading) return

Loading...
;

return (

Blog Posts

{posts.map((post: any) => (

{post.title}

{post.content.slice(0, 200)}...

By {post.author.name} {post.views} views

))}

);

}

Apollo Client Setup (Better Approach)

Use Apollo Client for advanced features.

typescript

// lib/apollo-client.ts

import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client";

import { setContext } from "@apollo/client/link/context";

const httpLink = createHttpLink({

uri: "/api/graphql",

});

const authLink = setContext((_, { headers }) => {

const token = localStorage.getItem("token");

return {

headers: {

...headers,

authorization: token ? Bearer ${token} : "",

},

};

});

export const apolloClient = new ApolloClient({

link: authLink.concat(httpLink),

cache: new InMemoryCache(),

});

// app/providers.tsx

("use client");

import { ApolloProvider } from "@apollo/client";

import { apolloClient } from "@/lib/apollo-client";

export function Providers({ children }: { children: React.ReactNode }) {

return {children};

}

// Usage in components

("use client");

import { useQuery, gql } from "@apollo/client";

const GET_POSTS = gql

query GetPosts {

posts(published: true) {

posts {

id

title

author {

name

}

}

}

}

;

export function Posts() {

const { data, loading, error } = useQuery(GET_POSTS);

if (loading) return

Loading...

;

if (error) return

Error: {error.message}

;

return (

{data.posts.posts.map((post: any) => (

{post.title}

))}

);

}

Testing GraphQL APIs

Write comprehensive tests.

typescript

// tests/graphql/user.test.ts

import { createTestContext } from "./__helpers";

describe("User Queries", () => {

it("should return current user when authenticated", async () => {

const { query, token } = await createTestContext();

const result = await query({

query:

query {

me {

id

email

name

}

}

,

headers: {

authorization: Bearer ${token},

},

});

expect(result.data.me).toBeDefined();

expect(result.data.me.email).toBe("test@example.com");

});

it("should throw error when not authenticated", async () => {

const { query } = await createTestContext();

const result = await query({

query:

query {

me {

id

}

}

,

});

expect(result.errors).toBeDefined();

expect(result.errors[0].extensions.code).toBe("UNAUTHENTICATED");

});

});

Best Practices

1. Schema-First Design - Design schema before implementation

2. DataLoader - Batch and cache database queries

3. Pagination - Always implement cursor or offset pagination

4. Error Handling - Use GraphQLError with proper codes

5. Rate Limiting - Protect against malicious queries

6. Query Complexity - Limit query depth and complexity

7. N+1 Problem - Use DataLoader to solve

8. Caching - Implement Redis for frequently accessed data

Conclusion

GraphQL with TypeScript and Next.js provides unmatched type safety and developer experience. The combination of code generation, Prisma, and Apollo Server creates a robust, scalable API architecture.

Start with a solid schema design, leverage code generation for type safety, and you'll build APIs that are a joy to work with! 🚀