Revisiting GraphQL in 2025: A Type-Safe Stack with Pothos and Relay
This article explores building a GraphQL server in 2025 using a modern, type-safe stack. We'll cover the tooling choices, backend setup with Pothos and Prisma, and frontend integration using Relay, highlighting the benefits for developer experience and application robustness. If you prefer GFM markdown , read it on github code reposotory Section 1: Setting the Stage - Choosing the Right Tools for Type Safety This section outlines the project requirements, the specific technology stack chosen, and the critical decision-making process for selecting libraries that ensure end-to-end type safety, ultimately leading to Pothos and Relay. When tasked with building a new GraphQL server in 2025, the core requirements mandated the use of: Node.js Express GraphQL Prisma PostgreSQL TypeScript The primary challenge was identifying tools that integrate seamlessly and provide strong end-to-end type safety guarantees. In an ideal scenario, the GraphQL types would be directly derived from the database schema managed by Prisma, minimizing type drift and manual synchronization efforts. After evaluating several options, the choice narrowed down to two main contenders for building the GraphQL schema layer on top of Prisma: Nexus: While a popular choice in the past, it appeared to lack support for the latest versions of Prisma at the time of evaluation, making it less suitable. TypeGraphQL: Having used TypeGraphQL previously with TypeORM, I knew it worked well in that ecosystem. However, Prisma's schema-first approach differs significantly from TypeORM's entity-based model. I was uncertain how well Prisma's schema definition would align with the decorator-heavy, class-based approach central to TypeGraphQL. Pothos: This library stood out due to its dedicated Prisma plugin (prisma-pothos-types), specifically designed to generate GraphQL types directly from the Prisma schema. This seemed like a natural fit for the project's goals. Further investigation into Pothos revealed excellent support for Relay, including helpers for connections and node interfaces. This was a significant advantage, as the decision between using Relay or Apollo on the client-side was still pending. The strong type-safety features Relay offers, particularly for handling pagination and filtering, ultimately tipped the scales in its favor. Consequently, Pothos became the clear choice for the schema builder. Section 2: Backend Implementation - Pothos, Prisma, and Express Integration Here, we delve into the practical backend setup. This includes defining the database models with Prisma, configuring the Pothos schema builder, integrating it into an Express application using GraphQL Yoga, and creating GraphQL types, including derived fields. The project itself was envisioned as a simple social network. For brevity and focus, we'll concentrate on the GraphQL-specific aspects, particularly around the Post model, omitting the general Express and TypeScript boilerplate. (The complete setup can be found here). Let's examine the core Prisma schema, focusing on the User and Post models: generator client { provider = "prisma-client-js" output = "./generated/client" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // Pothos generator to create types from Prisma models generator pothos { provider = "prisma-pothos-types" } model User { id String @id name String email String @unique emailVerified Boolean image String? createdAt DateTime @default(now()) // Corrected: Added default updatedAt DateTime @updatedAt sessions Session[] accounts Account[] // Social aspects posts Post[] likes Like[] comments Comment[] // Follow relationships followers Follow[] @relation("following") following Follow[] @relation("follower") role String? banned Boolean? banReason String? banExpires DateTime? apikeys Apikey[] @@map("user") } model Post { id String @id @default(ulid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt content String imageUrl String? // Relations author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String likes Like[] comments Comment[] } model Like { id String @id @default(ulid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String post Post @relation(fields: [postId], references: [id], onDelete: Cascade) postId String // Ensure a user can only like a post once @@unique([userId, postId]) } Next, we configure the Pothos schema builder, specifying the Prisma client and enabling plugins like Relay support: // Define the generic types for the Pothos builder, i

This article explores building a GraphQL server in 2025 using a modern, type-safe stack. We'll cover the tooling choices, backend setup with Pothos and Prisma, and frontend integration using Relay, highlighting the benefits for developer experience and application robustness.
If you prefer GFM markdown , read it on github
Section 1: Setting the Stage - Choosing the Right Tools for Type Safety
This section outlines the project requirements, the specific technology stack chosen, and the critical decision-making process for selecting libraries that ensure end-to-end type safety, ultimately leading to Pothos and Relay.
When tasked with building a new GraphQL server in 2025, the core requirements mandated the use of:
- Node.js
- Express
- GraphQL
- Prisma
- PostgreSQL
- TypeScript
The primary challenge was identifying tools that integrate seamlessly and provide strong end-to-end type safety guarantees. In an ideal scenario, the GraphQL types would be directly derived from the database schema managed by Prisma, minimizing type drift and manual synchronization efforts.
After evaluating several options, the choice narrowed down to two main contenders for building the GraphQL schema layer on top of Prisma:
- Nexus: While a popular choice in the past, it appeared to lack support for the latest versions of Prisma at the time of evaluation, making it less suitable.
- TypeGraphQL: Having used
TypeGraphQL
previously withTypeORM
, I knew it worked well in that ecosystem. However, Prisma's schema-first approach differs significantly from TypeORM's entity-based model. I was uncertain how well Prisma's schema definition would align with the decorator-heavy, class-based approach central toTypeGraphQL
. - Pothos: This library stood out due to its dedicated Prisma plugin (
prisma-pothos-types
), specifically designed to generate GraphQL types directly from the Prisma schema. This seemed like a natural fit for the project's goals.
Further investigation into Pothos revealed excellent support for Relay, including helpers for connections and node interfaces. This was a significant advantage, as the decision between using Relay or Apollo on the client-side was still pending. The strong type-safety features Relay offers, particularly for handling pagination and filtering, ultimately tipped the scales in its favor. Consequently, Pothos became the clear choice for the schema builder.
Section 2: Backend Implementation - Pothos, Prisma, and Express Integration
Here, we delve into the practical backend setup. This includes defining the database models with Prisma, configuring the Pothos schema builder, integrating it into an Express application using GraphQL Yoga, and creating GraphQL types, including derived fields.
The project itself was envisioned as a simple social network. For brevity and focus, we'll concentrate on the GraphQL-specific aspects, particularly around the Post
model, omitting the general Express and TypeScript boilerplate. (The complete setup can be found here).
Let's examine the core Prisma schema, focusing on the User
and Post
models:
generator client {
provider = "prisma-client-js"
output = "./generated/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Pothos generator to create types from Prisma models
generator pothos {
provider = "prisma-pothos-types"
}
model User {
id String @id
name String
email String @unique
emailVerified Boolean
image String?
createdAt DateTime @default(now()) // Corrected: Added default
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
// Social aspects
posts Post[]
likes Like[]
comments Comment[]
// Follow relationships
followers Follow[] @relation("following")
following Follow[] @relation("follower")
role String?
banned Boolean?
banReason String?
banExpires DateTime?
apikeys Apikey[]
@@map("user")
}
model Post {
id String @id @default(ulid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
content String
imageUrl String?
// Relations
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
likes Like[]
comments Comment[]
}
model Like {
id String @id @default(ulid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String
// Ensure a user can only like a post once
@@unique([userId, postId])
}
Next, we configure the Pothos schema builder, specifying the Prisma client and enabling plugins like Relay support:
// Define the generic types for the Pothos builder, including Prisma types and Context
export type PothosBuilderGenericType = { // Corrected Typo: PothosBuilderGenericTYpe -> PothosBuilderGenericType
PrismaTypes: PrismaTypes;
Context: {
currentUser?: Pick<User, "id" | "email" | "name">; // Context includes optional current user
};
};
// Instantiate the builder with necessary plugins and configurations
export const builder = new SchemaBuilder<PothosBuilderGenericType>({ // Corrected Typo: PothosBuilderGenericTYpe -> PothosBuilderGenericType
plugins: [PrismaPlugin, RelayPlugin], // Enable Prisma and Relay plugins
relay: {}, // Basic Relay configuration
prisma: {
client: prisma, // Provide the Prisma client instance
// Expose Prisma schema /// comments as GraphQL descriptions
exposeDescriptions: true,
// Use Prisma's filtering capabilities for Relay connection total counts
filterConnectionTotalCount: true,
// Warn about unused query parameters during development
onUnusedQuery: process.env.NODE_ENV === "production" ? null : "warn",
},
});
This builder is then used to generate the executable GraphQL schema, which is passed to the GraphQL Yoga server integrated with Express:
// graphql/builder.ts
import { lexicographicSortSchema, printSchema } from "graphql";
// Generate the schema object from the Pothos builder
export const pothosSchema = builder.toSchema();
// Export the schema definition as a string (SDL)
export const pothosSchemaString = printSchema(lexicographicSortSchema(pothosSchema));
// index.ts - Server setup
import express from 'express'; // Added import for clarity
import { createYoga } from 'graphql-yoga'; // Added import for clarity
import { fromNodeHeaders } from '@whatwg-node/server'; // Added import for clarity
import { auth } from './auth'; // Assuming auth setup exists
import { prisma } from './prismaClient'; // Assuming prisma client export exists
import { PothosBuilderGenericType, builder, pothosSchema, pothosSchemaString } from './graphql/builder'; // Assuming builder exports exist
// import { PrismaClient, User } from '@prisma/client'; // Assuming Prisma types import
const app = express(); // Added instantiation
const port = process.env.PORT || 4000; // Added port definition
// Configure GraphQL Yoga server
const yoga = createYoga<{
req: express.Request;
res: express.Response;
}>({
// Use Apollo Sandbox for the GraphiQL interface
renderGraphiQL: () => {
// HTML to embed Apollo Sandbox
return `
`;
},
schema: pothosSchema, // Pass the generated Pothos schema
// Define the context function to inject data (like authenticated user) into resolvers
context: async (ctx): Promise<PothosBuilderGenericType['Context']> => { // Typed context return
try {
const session = await auth.api.getSession({ // Assuming auth setup provides getSession
headers: fromNodeHeaders(ctx.req.headers),
});
if (!session?.user) { // Check specifically for user object in session
return {
currentUser: undefined, // Explicitly undefined if no user
};
}
// Provide relevant user details to the context
return {
currentUser: {
id: session.user.id,
email: session.user.email ?? undefined, // Handle potentially null email
name: session.user.name ?? undefined, // Handle potentially null name
},
};
} catch (error) {
console.error("Error resolving context:", error); // Add error logging
return { currentUser: undefined };
}
},
graphiql: true, // Enable GraphiQL interface
logging: true, // Enable logging
cors: true, // Enable CORS
});
// Bind GraphQL Yoga to the /graphql endpoint
// @ts-expect-error - Yoga types might mismatch slightly with Express middleware types
app.use(yoga.graphqlEndpoint, yoga);
// Define a simple root query required by GraphQL
builder.queryType({
fields: (t) => ({
hello: t.string({
resolve: () => "Hello world!",
}),
// Other root queries will be added here...
}),
});
// Placeholder for other express routes/middleware
// app.get('/', (req, res) => res.send('Server is running!'));
app.listen(port, () => {
console.log(`