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

Apr 15, 2025 - 09:56
 0
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:

  1. 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.
  2. 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.
  3. 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(`