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.
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:
typescript
1generator client {
2 provider ="prisma-client-js"
3 output ="./generated/client"
4}
5
6datasource db {
7 provider ="postgresql"
8 url =env("DATABASE_URL")
9}
10
11// Pothos generator to create types from Prisma models
79// Define a simple root query required by GraphQL
80builder.queryType({
81fields:(t)=>({
82 hello: t.string({
83resolve:()=>"Hello world!",
84}),
85// Other root queries will be added here...
86}),
87});
88
89// Placeholder for other express routes/middleware
90// app.get('/', (req, res) => res.send('Server is running!'));
91
92app.listen(port,()=>{
93console.log(`🚀 Server ready at http://localhost:${port}/graphql`);
94console.log(`🚀 GraphQL Playground available at http://localhost:${port}/graphql`);// Adjusted log message
95});
With the basic server running, we can define GraphQL types based on our Prisma models. Pothos makes this straightforward. We could create a direct 1-to-1 mapping:
typescript
1// Example of a simple Post type mapping (Not the final version used)
9 createdAt: t.exposeString("createdAt", { // Directly expose createdAt as string
10 type: "String", // Define the GraphQL type
11 }),
12 updatedAt: t.exposeString("updatedAt", { // Directly expose updatedAt as string
13 type: "String",
14 }),
15 // Example resolver for the author relation
16 postedBy: t.relation("author", {
17 type: UserType // Assuming UserType is defined elsewhere
18 })
19 }),
20});
21*/
However, for a feed, we often need derived data specific to the viewing user (e.g., "Have I liked this post?"). Pothos allows defining variants of Prisma types or adding custom fields easily. Here's the FeedPost type incorporating likeCount and likedByMe:
typescript
1// Define the Fren (User) type first if not already defined
2// Assuming a basic User type 'Fren' exists or is defined similarly
3exportconst Fren = builder.prismaNode("User",{// Example Fren type definition
4 id:{ field:"id"},
5fields:(t)=>({
6 frenId: t.exposeString("id"),// Expose 'id' as 'frenId'
7 name: t.exposeString("name"),
8 email: t.exposeString("email"),
9 image: t.exposeString("image"),
10// Add other user fields as needed
11}),
12});
13
14
15// Define the enhanced FeedPost type using prismaNode and custom fields
32resolve:(post)=> post.updatedAt.toISOString(),// Corrected: use updatedAt
33}),
34// Field resolving the User who posted this
35 postedBy: t.field({
36 type: Fren,// Reference the 'Fren' (User) type
37nullable:false,// Author should always exist
38resolve:async(parent, args, context)=>{
39// Fetch the author using the authorId from the parent Post
40const author =await prisma.user.findUnique({
41 where:{ id: parent.authorId },
42});
43if(!author){
44// Handle case where author is somehow not found, though schema constraints should prevent this
45thrownewError(`Author not found for post ${parent.id}`);
46}
47return author;
48},
49}),
50// Custom field to calculate the number of likes
51 likeCount: t.field({
52 type:"Int",
53resolve:async(parent)=>{
54// Count likes associated with the parent Post's id
55return prisma.like.count({
56 where:{ postId: parent.id },
57});
58},
59}),
60// Custom field to check if the current user liked this post
61 likedByMe: t.field({
62 type:"Boolean",
63resolve:async(parent, args, context)=>{
64// If no user is logged in, they haven't liked it
65if(!context.currentUser?.id)returnfalse;
66// Check if a Like record exists for this user and post
67const like =await prisma.like.findUnique({// Use findUnique for efficiency
68 where:{
69 userId_postId:{// Use the @@unique constraint defined in Prisma
70 userId: context.currentUser.id,
71 postId: parent.id,
72}
73},
74});
75// Return true if a like exists, false otherwise
76return!!like;
77},
78}),
79}),
80});
Finally, we add a query to fetch posts, utilizing Pothos's Relay connection helper (prismaConnection) for automatic pagination setup:
typescript
1// Extend the root query type with a field to fetch feed posts
2builder.queryType({
3fields:(t)=>({
4// ... existing fields like 'hello'
5 hello: t.string({// Keeping the hello query from before
6resolve:()=>"Hello world!",
7}),
8// Define the feedPosts query using Relay connections
9 feedPosts: t.prismaConnection({
10 type: FeedPost,// The type of nodes in the connection
11 cursor:"id",// Field used for cursor-based pagination
12resolve:(query, parent, args, context, info)=>{
13// Resolve by fetching posts from Prisma, applying connection arguments (like 'first', 'after')
14return prisma.post.findMany({
15...query,// Spreads Relay arguments (first, after, etc.) into Prisma query
16 orderBy:{
17 createdAt:"desc",// Order posts by creation date, newest first
18},
19});
20},
21}),
22}),
23});
Section 3: Frontend Integration - Consuming the API with Relay Fragments
This section transitions to the frontend, demonstrating how to leverage Relay's fragment-driven architecture. We'll cover fetching the schema, defining GraphQL fragments co-located with React components, and using Relay hooks to fetch and display data.
Pothos's built-in support for Relay connections is crucial here. The t.prismaConnection helper automatically generates the necessary GraphQL types for Relay pagination (like QueryFeedPostsConnection and QueryFeedPostsConnectionEdge), saving significant boilerplate.
graphql
1# Auto-generated GraphQL types by Pothos prismaConnection
2typeQueryFeedPostsConnection{
3edges:[QueryFeedPostsConnectionEdge]# List of edges (cursor + node)
4pageInfo:PageInfo!# Information about the current page
5}
6
7typeQueryFeedPostsConnectionEdge{
8cursor:String!# Opaque cursor for pagination
9node:FeedPost# The actual Post data
10}
11
12# Standard Relay PageInfo type
13typePageInfo{
14hasNextPage:Boolean!
15hasPreviousPage:Boolean!
16startCursor:String
17endCursor:String
18}
Why is this structure important? Relay relies heavily on this standardized connection model for efficient pagination and data fetching.
To integrate with the frontend (assuming a React setup with Relay configured), a common first step is to fetch the latest GraphQL Schema Definition Language (SDL) generated by our backend API. This allows the Relay compiler to validate queries and generate types.
typescript
1// Example API endpoint to serve the SDL
2// (Add this within your Express setup in index.ts or a separate routes file)
3app.get("/sdl",(req, res)=>{
4 res.type("application/graphql").send(pothosSchemaString);// Set content type
5});
6
7// Example script on the frontend (e.g., scripts/fetchSdl.ts)
8import"dotenv/config";// If using environment variables for API URL
9import fs from"fs/promises";
10
11exportasyncfunctiongetSdl(){
12try{
13const apiUrl = process.env.VITE_API_URL||"http://localhost:4000";// Default or from env
14console.log(`Workspaceing SDL from ${apiUrl}/sdl...`);
15const res =awaitfetch(`${apiUrl}/sdl`);
16if(!res.ok){
17thrownewError(`Failed to fetch SDL: ${res.status}${res.statusText}`);
18}
19const sdl =await res.text();
20await fs.writeFile("./schema.graphql", sdl);// Save to root or specified path
21console.log("✅ SDL fetched and saved to schema.graphql");
27// Run the script (e.g., via package.json script)
28getSdl();
Relay encourages a "colocation" principle: data requirements (fragments) are defined alongside the components that use them. This differs from traditional REST approaches where a parent component might fetch all data and pass it down. In Relay, leaf components define their data needs via fragments, which are composed upwards into parent fragments and finally into a single page query.
Here's how fragments might look for our social feed:
graphql
1# src/components/FeedCard.tsx (or similar) - Fragment defining data needed by a single post card
2# Naming Convention: ComponentName_propName
3exportconstFeedCardFragment=graphql`
4fragmentFeedCard_postonFeedPost{
5id# Global Relay ID
6postId# Our application-specific ID
7content
8imageUrl
9createdAt
10likeCount
11likedByMe
12updatedAt
13postedBy{
14# We can include fragments from other components here too if needed
15# Or specify the fields directly:
16frenId# User's ID (exposed as frenId in our Fren type)
17name
18email
19image
20# Assuming 'amFollowing' fields were added to the 'Fren' type on the backend
21# amFollowing
22}
23}
24`;
25
26# src/components/MainFeed.tsx - Fragment defining the list of posts needed by the feed container
27exportconstMainFeedFragment=graphql`
28# Fragment on the Query type, defining arguments for pagination
Section 4: Advanced Relay - Effortless Pagination and Mutation Handling
This final section covers more advanced Relay capabilities facilitated by Pothos and Relay's design. We'll implement infinite scrolling/pagination using usePaginationFragment and demonstrate how Relay handles data mutations (updates, creates, deletes) with automatic and manual cache management.
While the initial setup fetches posts, real-world feeds require pagination (e.g., infinite scroll or "Load More"). Relay excels here, especially when combined with Pothos's connection fields.
First, we modify the MainFeedFragment to make it suitable for pagination using the @refetchable and @connection directives:
graphql
1# src/components/MainFeed.tsx - Updated fragment for pagination
2exportconstMainFeedFragment=graphql`
3fragmentMainFeed_feedPostsonQuery
4# Define arguments for pagination, Relay needs these defined here
5@argumentDefinitions(
6first:{type:"Int",defaultValue:10},# Default items per page
7after:{type:"String"}# Cursor to fetch items after
8)
9# Make this fragment refetchable, generating a MainFeedPaginationQuery
This setup provides smooth pagination with minimal manual effort. If you've wrestled with pagination logic using libraries like Apollo Client, the simplicity here is particularly noteworthy.
Finally, let's look at mutations (creating, updating, deleting data). A great feature of Relay is that if a mutation returns the same fragment that was mutated (identified by the global id), Relay often updates the local store automatically.
3mutationPostDialogsEditMutation($id:ID!,$content:String,$imageUrl:String){# Use global ID!
4# Assume backend mutation 'updatePost' takes global ID
5updatePost(input:{id:$id,content:$content,imageUrl:$imageUrl}){# Example input object
6# Return the fragment for the updated post
7updatedPostEdge{# Assuming mutation returns an edge or node
8node{
9...FeedCard_post# Spreading the fragment triggers automatic update if ID matches
10}
11}
12}
13}
14`;
However, for creating new items or deleting existing ones, the cache doesn't automatically know where the new item should go in a list (connection) or that an item should be removed. We need to provide an updater function.
By combining Pothos on the backend for easy schema generation and Relay integration with Relay on the frontend for its powerful data fetching, fragmentation, and cache management capabilities, we achieved a highly type-safe and efficient GraphQL setup for this 2025 project. The synergy between these tools significantly improves the developer experience when dealing with complex data interactions.