Building Type-Safe GraphQL APIs with TypeScript and Next.js
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
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! 🚀