Back to blogs
Blog
Writing in public
Longer posts — published here first, then cross-posted to Dev.to with a canonical URL back to this site.
Blog
Longer posts — published here first, then cross-posted to Dev.to with a canonical URL back to this site.
Back to blogs
Back to blogs
Post
This article explores building a GraphQL server in 2025 using a modern, type-safe stack. We'll cover...
Date published

Why You Might Still Pick Next.js Over TanStack Start From a TanStack Start...

A complete theme management system for React applications with SSR support! ✨ 🌟...

A guide to creating Android home screen widgets using Expo modules, complete with state management,...
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
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:
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:
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.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.
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:
1generator client {2 provider = "prisma-client-js"3 output = "./generated/client"4}56datasource db {7 provider = "postgresql"8 url = env("DATABASE_URL")9}1011// Pothos generator to create types from Prisma models12generator pothos {13 provider = "prisma-pothos-types"14}1516model User {17 id String @id18 name String19 email String @unique20 emailVerified Boolean21 image String?22 createdAt DateTime @default(now()) // Corrected: Added default23 updatedAt DateTime @updatedAt24 sessions Session[]25 accounts Account[]2627 // Social aspects28 posts Post[]29 likes Like[]30 comments Comment[]31 // Follow relationships32 followers Follow[] @relation("following")33 following Follow[] @relation("follower")34 role String?35 banned Boolean?36 banReason String?37 banExpires DateTime?3839 apikeys Apikey[]4041 @@map("user")42}4344model Post {45 id String @id @default(ulid())46 createdAt DateTime @default(now())47 updatedAt DateTime @updatedAt48 content String49 imageUrl String?50 // Relations51 author User @relation(fields: [authorId], references: [id], onDelete: Cascade)52 authorId String53 likes Like[]54 comments Comment[]55}5657model Like {58 id String @id @default(ulid())59 createdAt DateTime @default(now())60 updatedAt DateTime @updatedAt61 // Relations62 user User @relation(fields: [userId], references: [id], onDelete: Cascade)63 userId String64 post Post @relation(fields: [postId], references: [id], onDelete: Cascade)65 postId String6667 // Ensure a user can only like a post once68 @@unique([userId, postId])69}
Next, we configure the Pothos schema builder, specifying the Prisma client and enabling plugins like Relay support:
1// Define the generic types for the Pothos builder, including Prisma types and Context2export type PothosBuilderGenericType = { // Corrected Typo: PothosBuilderGenericTYpe -> PothosBuilderGenericType3 PrismaTypes: PrismaTypes;4 Context: {5 currentUser?: Pick<User, "id" | "email" | "name">; // Context includes optional current user6 };7};89// Instantiate the builder with necessary plugins and configurations10export const builder = new SchemaBuilder<PothosBuilderGenericType>({ // Corrected Typo: PothosBuilderGenericTYpe -> PothosBuilderGenericType11 plugins: [PrismaPlugin, RelayPlugin], // Enable Prisma and Relay plugins12 relay: {}, // Basic Relay configuration13 prisma: {14 client: prisma, // Provide the Prisma client instance15 // Expose Prisma schema /// comments as GraphQL descriptions16 exposeDescriptions: true,17 // Use Prisma's filtering capabilities for Relay connection total counts18 filterConnectionTotalCount: true,19 // Warn about unused query parameters during development20 onUnusedQuery: process.env.NODE_ENV === "production" ? null : "warn",21 },22});
This builder is then used to generate the executable GraphQL schema, which is passed to the GraphQL Yoga server integrated with Express:
1// graphql/builder.ts2import { lexicographicSortSchema, printSchema } from "graphql";3// Generate the schema object from the Pothos builder4export const pothosSchema = builder.toSchema();56// Export the schema definition as a string (SDL)7export const pothosSchemaString = printSchema(lexicographicSortSchema(pothosSchema));8910// index.ts - Server setup11import express from 'express'; // Added import for clarity12import { createYoga } from 'graphql-yoga'; // Added import for clarity13import { fromNodeHeaders } from '@whatwg-node/server'; // Added import for clarity14import { auth } from './auth'; // Assuming auth setup exists15import { prisma } from './prismaClient'; // Assuming prisma client export exists16import { PothosBuilderGenericType, builder, pothosSchema, pothosSchemaString } from './graphql/builder'; // Assuming builder exports exist17// import { PrismaClient, User } from '@prisma/client'; // Assuming Prisma types import1819const app = express(); // Added instantiation20const port = process.env.PORT || 4000; // Added port definition2122// Configure GraphQL Yoga server23const yoga = createYoga<{24 req: express.Request;25 res: express.Response;26}>({27 // Use Apollo Sandbox for the GraphiQL interface28 renderGraphiQL: () => {29 // HTML to embed Apollo Sandbox30 return `31 <!DOCTYPE html>32 <html lang="en">33 <body style="margin: 0; overflow-x: hidden; overflow-y: hidden">34 <div id="sandbox" style="height:100vh; width:100vw;"></div>35 <script src="https://embeddable-sandbox.cdn.apollographql.com/_latest/embeddable-sandbox.umd.production.min.js"></script>36 <script>37 new window.EmbeddedSandbox({38 target: "#sandbox",39 initialEndpoint: "http://localhost:${port}/graphql", // Dynamic port40 });41 </script>42 </body>43 </html>`;44 },45 schema: pothosSchema, // Pass the generated Pothos schema46 // Define the context function to inject data (like authenticated user) into resolvers47 context: async (ctx): Promise<PothosBuilderGenericType['Context']> => { // Typed context return48 try {49 const session = await auth.api.getSession({ // Assuming auth setup provides getSession50 headers: fromNodeHeaders(ctx.req.headers),51 });52 if (!session?.user) { // Check specifically for user object in session53 return {54 currentUser: undefined, // Explicitly undefined if no user55 };56 }57 // Provide relevant user details to the context58 return {59 currentUser: {60 id: session.user.id,61 email: session.user.email ?? undefined, // Handle potentially null email62 name: session.user.name ?? undefined, // Handle potentially null name63 },64 };65 } catch (error) {66 console.error("Error resolving context:", error); // Add error logging67 return { currentUser: undefined };68 }69 },70 graphiql: true, // Enable GraphiQL interface71 logging: true, // Enable logging72 cors: true, // Enable CORS73});7475// Bind GraphQL Yoga to the /graphql endpoint76// @ts-expect-error - Yoga types might mismatch slightly with Express middleware types77app.use(yoga.graphqlEndpoint, yoga);7879// Define a simple root query required by GraphQL80builder.queryType({81 fields: (t) => ({82 hello: t.string({83 resolve: () => "Hello world!",84 }),85 // Other root queries will be added here...86 }),87});8889// Placeholder for other express routes/middleware90// app.get('/', (req, res) => res.send('Server is running!'));9192app.listen(port, () => {93 console.log(`🚀 Server ready at http://localhost:${port}/graphql`);94 console.log(`🚀 GraphQL Playground available at http://localhost:${port}/graphql`); // Adjusted log message95});
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:
1// Example of a simple Post type mapping (Not the final version used)2/*3export const SimplePostType = builder.prismaNode("Post", {4 id: { field: "id" },5 fields: (t) => ({6 postId: t.exposeString("id", { nullable: false }), // Expose Prisma 'id' as 'postId'7 content: t.exposeString("content"),8 imageUrl: t.exposeString("imageUrl"),9 createdAt: t.exposeString("createdAt", { // Directly expose createdAt as string10 type: "String", // Define the GraphQL type11 }),12 updatedAt: t.exposeString("updatedAt", { // Directly expose updatedAt as string13 type: "String",14 }),15 // Example resolver for the author relation16 postedBy: t.relation("author", {17 type: UserType // Assuming UserType is defined elsewhere18 })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:
1// Define the Fren (User) type first if not already defined2// Assuming a basic User type 'Fren' exists or is defined similarly3export const Fren = builder.prismaNode("User", { // Example Fren type definition4 id: { field: "id" },5 fields: (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 needed11 }),12});131415// Define the enhanced FeedPost type using prismaNode and custom fields16export const FeedPost = builder.prismaNode("Post", {17 // Using a variant allows multiple GraphQL types based on the same Prisma model if needed18 // variant: "FeedPost", // Optional: Define a variant name19 id: { field: "id" }, // Map the 'id' field for Relay Node interface20 fields: (t) => ({21 postId: t.exposeString("id", { nullable: false }), // Expose DB 'id' as 'postId'22 content: t.exposeString("content"),23 imageUrl: t.exposeString("imageUrl", { nullable: true }), // Explicitly nullable24 // Custom resolver for ISO string date format25 createdAt: t.field({26 type: "String",27 resolve: (post) => post.createdAt.toISOString(),28 }),29 // Custom resolver for ISO string date format30 updatedAt: t.field({31 type: "String",32 resolve: (post) => post.updatedAt.toISOString(), // Corrected: use updatedAt33 }),34 // Field resolving the User who posted this35 postedBy: t.field({36 type: Fren, // Reference the 'Fren' (User) type37 nullable: false, // Author should always exist38 resolve: async (parent, args, context) => {39 // Fetch the author using the authorId from the parent Post40 const author = await prisma.user.findUnique({41 where: { id: parent.authorId },42 });43 if (!author) {44 // Handle case where author is somehow not found, though schema constraints should prevent this45 throw new Error(`Author not found for post ${parent.id}`);46 }47 return author;48 },49 }),50 // Custom field to calculate the number of likes51 likeCount: t.field({52 type: "Int",53 resolve: async (parent) => {54 // Count likes associated with the parent Post's id55 return prisma.like.count({56 where: { postId: parent.id },57 });58 },59 }),60 // Custom field to check if the current user liked this post61 likedByMe: t.field({62 type: "Boolean",63 resolve: async (parent, args, context) => {64 // If no user is logged in, they haven't liked it65 if (!context.currentUser?.id) return false;66 // Check if a Like record exists for this user and post67 const like = await prisma.like.findUnique({ // Use findUnique for efficiency68 where: {69 userId_postId: { // Use the @@unique constraint defined in Prisma70 userId: context.currentUser.id,71 postId: parent.id,72 }73 },74 });75 // Return true if a like exists, false otherwise76 return !!like;77 },78 }),79 }),80});
Finally, we add a query to fetch posts, utilizing Pothos's Relay connection helper (prismaConnection) for automatic pagination setup:
1// Extend the root query type with a field to fetch feed posts2builder.queryType({3 fields: (t) => ({4 // ... existing fields like 'hello'5 hello: t.string({ // Keeping the hello query from before6 resolve: () => "Hello world!",7 }),8 // Define the feedPosts query using Relay connections9 feedPosts: t.prismaConnection({10 type: FeedPost, // The type of nodes in the connection11 cursor: "id", // Field used for cursor-based pagination12 resolve: (query, parent, args, context, info) => {13 // Resolve by fetching posts from Prisma, applying connection arguments (like 'first', 'after')14 return prisma.post.findMany({15 ...query, // Spreads Relay arguments (first, after, etc.) into Prisma query16 orderBy: {17 createdAt: "desc", // Order posts by creation date, newest first18 },19 });20 },21 }),22 }),23});
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.
1# Auto-generated GraphQL types by Pothos prismaConnection2type QueryFeedPostsConnection {3 edges: [QueryFeedPostsConnectionEdge] # List of edges (cursor + node)4 pageInfo: PageInfo! # Information about the current page5}67type QueryFeedPostsConnectionEdge {8 cursor: String! # Opaque cursor for pagination9 node: FeedPost # The actual Post data10}1112# Standard Relay PageInfo type13type PageInfo {14 hasNextPage: Boolean!15 hasPreviousPage: Boolean!16 startCursor: String17 endCursor: String18}
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.
1// Example API endpoint to serve the SDL2// (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 type5});67// Example script on the frontend (e.g., scripts/fetchSdl.ts)8import "dotenv/config"; // If using environment variables for API URL9import fs from "fs/promises";1011export async function getSdl() {12 try {13 const apiUrl = process.env.VITE_API_URL || "http://localhost:4000"; // Default or from env14 console.log(`Workspaceing SDL from ${apiUrl}/sdl...`);15 const res = await fetch(`${apiUrl}/sdl`);16 if (!res.ok) {17 throw new Error(`Failed to fetch SDL: ${res.status} ${res.statusText}`);18 }19 const sdl = await res.text();20 await fs.writeFile("./schema.graphql", sdl); // Save to root or specified path21 console.log("✅ SDL fetched and saved to schema.graphql");22 } catch (error) {23 console.error("❌ Error fetching SDL: ", error); // Improved error logging24 }25}2627// 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:
1# src/components/FeedCard.tsx (or similar) - Fragment defining data needed by a single post card2# Naming Convention: ComponentName_propName3export const FeedCardFragment = graphql`4 fragment FeedCard_post on FeedPost {5 id # Global Relay ID6 postId # Our application-specific ID7 content8 imageUrl9 createdAt10 likeCount11 likedByMe12 updatedAt13 postedBy {14 # We can include fragments from other components here too if needed15 # Or specify the fields directly:16 frenId # User's ID (exposed as frenId in our Fren type)17 name18 email19 image20 # Assuming 'amFollowing' fields were added to the 'Fren' type on the backend21 # amFollowing22 }23 }24`;2526# src/components/MainFeed.tsx - Fragment defining the list of posts needed by the feed container27export const MainFeedFragment = graphql`28 # Fragment on the Query type, defining arguments for pagination29 fragment MainFeed_feedPosts on Query @argumentDefinitions(30 first: { type: "Int", defaultValue: 10 }, # How many items to fetch31 after: { type: "String" } # Cursor for pagination32 ) {33 # Use the feedPosts connection field defined in our backend query34 feedPosts(first: $first, after: $after) {35 edges {36 node {37 id # Needed for mapping and keys38 ...FeedCard_post # Include the data requirements from the child component39 }40 }41 pageInfo { # Needed for pagination logic42 hasNextPage43 endCursor44 }45 }46 }47`;484950# src/pages/FeedPage.tsx (or container component) - The main query for the page51export const MainFeedQuery = graphql`52 # This query includes the MainFeed fragment, passing arguments down53 query MainFeedContainerQuery($first: Int!, $after: String) {54 ...MainFeed_feedPosts @arguments(first: $first, after: $after)55 }56`;57
Here's how these fragments and queries are used in React components:
1// src/pages/FeedPage.tsx - Root component for the feed view2import React from 'react';3import { useLazyLoadQuery } from 'react-relay';4import { MainFeedContainerQuery } from './__generated__/MainFeedContainerQuery.graphql'; // Import generated types5import { MainFeedQuery } from './MainFeed'; // Import the query definition6import { MainFeed } from '../components/MainFeed'; // Import the component using the fragment78export function FeedPage() { // Renamed component for clarity9 // Fetch the initial data for the page using the main query10 const queryData = useLazyLoadQuery<MainFeedContainerQuery>(MainFeedQuery, { first: 10 }); // Fetch initial 10 posts1112 return (13 <div className="w-full py-4">14 {/* Pass the query data (specifically the part matching the fragment) to the MainFeed component */}15 <MainFeed queryRef={queryData} />16 </div>17 );18}192021// src/components/MainFeed.tsx - Component rendering the list of posts22import React from 'react';23import { useFragment } from 'react-relay';24import { MainFeed_feedPosts$key } from './__generated__/MainFeed_feedPosts.graphql'; // Import fragment type25import { MainFeedFragment } from './MainFeed'; // Import fragment definition (assuming it's here or imported)26import { PostCard } from "./FeedCard"; // Import the child component2728interface FeedProps {29 queryRef: MainFeed_feedPosts$key; // Prop type expects the fragment $key30}3132export function MainFeed({ queryRef }: FeedProps) { // Renamed component33 // Use the useFragment hook to read data defined by the MainFeedFragment34 const data = useFragment(35 MainFeedFragment,36 queryRef37 );3839 // Extract post nodes safely40 const posts = data?.feedPosts?.edges?.map(edge => edge?.node) ?? []; // Use optional chaining and nullish coalescing4142 // Render the list of posts, passing each post's data (as a fragment ref) to PostCard43 return (44 <div className="w-full max-w-2xl mx-auto">45 {posts.map((post) => post && (46 // Pass the individual post fragment reference to the PostCard47 <PostCard key={post.id} postRef={post} />48 ))}49 {/* Pagination controls will be added later */}50 </div>51 );52}535455// src/components/FeedCard.tsx - Component rendering a single post56import React from 'react';57import { useFragment } from 'react-relay';58import { FeedCard_post$key } from "./__generated__/FeedCard_post.graphql"; // Import fragment type59import { FeedCardFragment } from './FeedCard'; // Import fragment definition (assuming it's here or imported)60// Assuming Card components are imported from a UI library like ShadCN/UI61import { Card, CardContent /* ... other Card parts */ } from '@/components/ui/card';626364interface PostCardProps {65 postRef: FeedCard_post$key; // Prop type expects the fragment $key for a single post66 // viewer?: BetterAuthViewer; // Example of passing other props if needed67}6869export function PostCard({ postRef }: PostCardProps) { // Removed viewer prop for simplicity70 // Use useFragment to read the data defined by FeedCardFragment71 const postData = useFragment<FeedCard_post$key>(FeedCardFragment, postRef);7273 // Early return if postData is somehow null/undefined (though Relay usually prevents this if ref is valid)74 if (!postData) {75 return null;76 }7778 // Example: Using derived data or formatting79 // const postIdFirstChars = postData?.id.substring(0, 2).toUpperCase();8081 return (82 <Card className="w-full mb-4 border-none bg-base-300">83 <CardContent className="pt-6">84 {/* Display post content, author info, like button, etc. using postData */}85 <p>{postData.content}</p>86 {/* ... other card elements ... */}87 <span>Likes: {postData.likeCount}</span>88 <span>{postData.likedByMe ? 'You liked this' : 'Like'}</span>89 </CardContent>90 </Card>91 )92}
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:
1# src/components/MainFeed.tsx - Updated fragment for pagination2export const MainFeedFragment = graphql`3 fragment MainFeed_feedPosts on Query4 # Define arguments for pagination, Relay needs these defined here5 @argumentDefinitions(6 first: { type: "Int", defaultValue: 10 }, # Default items per page7 after: { type: "String" } # Cursor to fetch items after8 )9 # Make this fragment refetchable, generating a MainFeedPaginationQuery10 @refetchable(queryName: "MainFeedPaginationQuery") {11 # Specify the connection field12 feedPosts(first: $first, after: $after)13 # Identify this specific connection in the Relay store14 @connection(key: "MainFeed_feedPosts", filters: []) {15 edges {16 cursor # Needed for pagination17 node {18 id19 ...FeedCard_post # Include child fragment20 }21 }22 pageInfo {23 endCursor24 hasNextPage # Crucial for knowing if more data is available25 # Optional:26 # hasPreviousPage27 # startCursor28 }29 }30 }31`;
Now, the MainFeed component can use the usePaginationFragment hook provided by Relay:
1// src/components/MainFeed.tsx - Updated component using usePaginationFragment2import React from 'react';3// Import the specific pagination query type generated by @refetchable4import { MainFeedPaginationQuery } from "./__generated__/MainFeedPaginationQuery.graphql";5import { MainFeed_feedPosts$key } from './__generated__/MainFeed_feedPosts.graphql';6import { usePaginationFragment } from 'react-relay';7import { PostCard } from "./FeedCard";8import { MainFeedFragment } from './MainFeed'; // Import fragment definition9// Assuming Button and Loader components are imported10import { Button } from '@/components/ui/button';11import { Loader2 } from 'lucide-react';1213interface FeedProps {14 queryRef: MainFeed_feedPosts$key;15}1617export function MainFeed({ queryRef }: FeedProps) {18 // Use usePaginationFragment hook19 const {20 data, // The accumulated data for the connection21 loadNext, // Function to load the next page22 hasNext, // Boolean indicating if more pages exist23 isLoadingNext // Boolean indicating if the next page is currently loading24 } = usePaginationFragment<MainFeedPaginationQuery, MainFeed_feedPosts$key>(25 MainFeedFragment, // The fragment definition26 queryRef // The fragment reference passed from the parent27 );2829 // Function to trigger loading more posts30 const loadMorePosts = () => {31 // Prevent multiple requests or loading if no more data32 if (isLoadingNext || !hasNext) return;33 loadNext(5); // Load the next 5 items (or adjust count as needed)34 };3536 // Extract post nodes from the accumulated data37 const posts = data?.feedPosts?.edges?.map(edge => edge?.node) ?? [];3839 return (40 <div className="w-full max-w-2xl mx-auto">41 {posts.map((post) => post && (42 <PostCard key={post.id} postRef={post} />43 ))}4445 {/* Display a "Load More" button if there's a next page */}46 {hasNext && (47 <div className="flex justify-center my-4">48 <Button onClick={loadMorePosts} variant="outline" disabled={isLoadingNext}>49 {isLoadingNext ? (50 <>51 <Loader2 className="mr-2 h-4 w-4 animate-spin" />52 Loading more posts...53 </>54 ) : (55 "Load More Posts"56 )}57 </Button>58 </div>59 )}60 </div>61 );62}
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.
For example, an edit mutation:
1# src/components/PostDialogs.tsx (or similar) - Edit Mutation2const editPostMutation = graphql`3 mutation PostDialogsEditMutation($id: ID!, $content: String, $imageUrl: String) { # Use global ID!4 # Assume backend mutation 'updatePost' takes global ID5 updatePost(input: {id: $id, content: $content, imageUrl: $imageUrl}) { # Example input object6 # Return the fragment for the updated post7 updatedPostEdge { # Assuming mutation returns an edge or node8 node {9 ...FeedCard_post # Spreading the fragment triggers automatic update if ID matches10 }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.
Creating a Post:
1# src/components/PostDialogs.tsx - Create Mutation2const createPostMutation = graphql`3 mutation PostDialogsCreateMutation($content: String!, $imageUrl: String) {4 # Assume backend mutation 'createPost' takes content/imageUrl5 createPost(input: { content: $content, imageUrl: $imageUrl }) {6 # Return the fragment for the newly created post, wrapped in an edge7 newPostEdge { # Standard Relay practice to return the new edge8 cursor9 node {10 ...FeedCard_post # Include the fragment data11 }12 }13 }14 }15`;
1// src/components/PostDialogs.tsx - Usage of create mutation2import { useMutation, ConnectionHandler } from 'react-relay';3import { PostDialogsCreateMutation } from './__generated__/PostDialogsCreateMutation.graphql'; // Generated type45// Inside your component...6const [commitCreateMutation, isCreating] = useMutation<PostDialogsCreateMutation>(createPostMutation);7// Assuming 'setError' state hook exists8const setError = (e: Error | null) => { /* ... */ };9// Assuming PostFormData type exists10type PostFormData = { content: string; imageUrl?: string };1112const handleCreateSubmit = (data: PostFormData) => {13 setError(null);14 commitCreateMutation({15 variables: {16 content: data.content,17 imageUrl: data.imageUrl || undefined, // Use undefined if optional18 },19 // Updater function to manually insert the new post into the connection20 updater: (store) => {21 // Get the newly created post edge from the mutation response payload22 const payload = store.getRootField("createPost"); // Matches mutation name23 const newEdge = payload?.getLinkedRecord("newPostEdge"); // Matches field in mutation response2425 if (!newEdge) {26 console.error("Failed to get new edge from createPost mutation payload");27 return;28 }2930 // Get the connection record from the store31 const root = store.getRoot();32 // Use the connection key defined in the @connection directive33 const connection = ConnectionHandler.getConnection(34 root,35 "MainFeed_feedPosts" // Must match the key in MainFeedFragment @connection36 );3738 if (!connection) {39 console.error("Failed to find connection MainFeed_feedPosts in store");40 return;41 }4243 // Insert the new edge at the beginning of the connection44 ConnectionHandler.insertEdgeBefore(connection, newEdge);45 },46 onError: (error) => {47 setError(error);48 console.error("Create post failed:", error);49 }50 });51};
Deleting a Post:
1# src/components/PostDialogs.tsx - Delete Mutation2const deletePostMutation = graphql`3 mutation PostDialogsDeleteMutation($id: ID!) { # Use global ID!4 deletePost(input: { id: $id }) {5 deletedPostId # Return the ID of the deleted post6 }7 }8`;
1// src/components/PostDialogs.tsx - Usage of delete mutation2import { useMutation, ConnectionHandler } from 'react-relay';3import { PostDialogsDeleteMutation } from './__generated__/PostDialogsDeleteMutation.graphql';45// Inside your component, assuming 'post' object with 'id' (global Relay ID) exists6// const post: { id: string, postId: string /* ... other fields */ };7const [commitDeleteMutation, isDeleting] =8 useMutation<PostDialogsDeleteMutation>(deletePostMutation);9const setError = (e: Error | null) => { /* ... */ };1011const handleDeletePost = () => {12 setError(null);1314 commitDeleteMutation({15 variables: {16 id: post.id, // Pass the global Relay ID17 },18 // Updater function to remove the node from the connection19 updater: (store) => {20 // Get the ID of the deleted post from the payload21 const payload = store.getRootField("deletePost");22 const deletedId = payload?.getValue("deletedPostId"); // Matches field in mutation2324 if (typeof deletedId !== 'string') {25 console.error("Could not get deletedPostId from payload");26 return;27 }2829 // Get the connection30 const root = store.getRoot();31 const connection = ConnectionHandler.getConnection(root, "MainFeed_feedPosts");3233 if (!connection) {34 console.error("Failed to find connection MainFeed_feedPosts in store");35 return;36 }3738 // Remove the node using its ID39 ConnectionHandler.deleteNode(connection, deletedId);40 },41 // Optimistic updater removes the item from the UI immediately42 optimisticUpdater: (store) => {43 const root = store.getRoot();44 const connection = ConnectionHandler.getConnection(root, "MainFeed_feedPosts");45 if (connection) {46 // Remove the node optimistically using its known ID47 ConnectionHandler.deleteNode(connection, post.id);48 }49 },50 onError: (error) => {51 setError(error);52 console.error("Delete post failed:", error);53 }54 });55};
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.
If you prefer GFM markdown , read it on github