Connections
Build Relay connections over Prisma models with cursor pagination, total counts, and shared connection objects.
Relay connections give you cursor-based pagination over a list. The Prisma plugin implements them on top of Prisma's own cursor pagination, and pre-loads the data nested inside each page in the same optimized query as the rest of the request. Use t.prismaConnection on a root field, t.relatedConnection for a relation of a node, and prismaConnectionHelpers when the list lives behind a join table.
These examples assume the builder is set up with PrismaPlugin and RelayPlugin and a prisma client in scope (see Setup).
A connection on a root field
t.prismaConnection defines a Relay connection field and preloads everything the page needs. The resolver receives a query object as its first argument. Spread it into your Prisma call; it carries the correct take, skip, and cursor derived from the connection arguments, plus the include/select for nested selections.
builder.queryType({
fields: (t) => ({
players: t.prismaConnection(
{
type: 'Player',
cursor: 'id',
resolve: (query, _parent, _args, _ctx, _info) =>
prisma.player.findMany({ ...query }),
},
{}, // optional options for the Connection type
{}, // optional options for the Edge type
),
}),
});The three arguments are the field options, then optional options for the generated Connection type, then optional options for the Edge type.
| Option | Purpose |
|---|---|
type | Name of the Prisma model being connected to. |
cursor | A @unique column (or unique index) of that model, passed to Prisma as the cursor. |
resolve | Like the prismaField resolver: spread the first query argument into your Prisma query and return the array of nodes. |
defaultSize | Page size when neither first nor last is given. Default 20. |
maxSize | Maximum number of nodes returned. Default 100. |
totalCount | A function (parent, args, context, info) that loads the total count and adds a totalCount field to the connection. Its parent is the connection field's parent, like the prismaField resolver. Does not apply with a shared connection object (see Total count on shared objects). |
defaultSize and maxSize accept a plain number or a function of (args, context). You can also set them for every connection at once with the maxConnectionSize and defaultConnectionSize options in the prisma plugin options.
Prisma-backed connections support only the argument combinations that map to an efficient cursor query: first, last, or before on their own; first with before; and last with after. Other combinations would require loading every record between two cursors (or between a cursor and the end of the set), which is complex and inefficient, so they throw an error indicating the combination is unsupported.
A connection from a relation
t.relatedConnection builds a connection from a relation of the current model, with no resolver needed since the relation names the data. It works on any Prisma object, and pairs naturally with a node.
builder.prismaNode('Team', {
id: { field: 'id' },
fields: (t) => ({
name: t.exposeString('name'),
// The simplest form: just a cursor.
roster: t.relatedConnection('players', {
cursor: 'id',
}),
// Or add arguments and a custom query merged into the relation.
players: t.relatedConnection(
'players',
{
cursor: 'id',
args: {
sortByNumber: t.arg.boolean(),
},
query: (args, _context) => ({
orderBy: {
number: args.sortByNumber ? 'asc' : 'desc',
},
}),
},
{}, // optional options for the Connection type
{}, // optional options for the Edge type
),
}),
});| Option | Purpose |
|---|---|
cursor | A @unique column of the related model, passed to Prisma as the cursor. |
defaultSize | Page size when neither first nor last is given. Default 20. |
maxSize | Maximum number of nodes returned. Default 100. |
query | A function of (args, context) returning filtering and sorting merged into the query for the relation. |
totalCount | Set true to add a totalCount field to the connection, like relationCount. Does not apply when using a shared connection object. |
Indirect relations as connections
When the list you want to paginate lives behind a join table, t.relatedConnection doesn't fit, because you paginate the join rows but return the nodes nested one level deeper. prismaConnectionHelpers handles this with a plain t.connection field.
In the league schema, PlayerStat joins Player to Game: paginating a player's games means paginating that player's stats and resolving each edge to its game.
// The node type of the connection.
const Game = builder.prismaObject('Game', {
select: {
id: true,
},
fields: (t) => ({
playedAt: t.expose('playedAt', { type: 'DateTime' }),
}),
});
// Connection helpers for the join table let you use a plain t.connection.
const gameConnectionHelpers = prismaConnectionHelpers(
builder,
'PlayerStat', // the join table
{
cursor: 'id',
select: (nodeSelection) => ({
// Select the relation to the node using nodeSelection.
game: nodeSelection({
// Fields to select by default for the node.
select: {
id: true,
},
}),
}),
// Resolve the node from the edge (join) row.
resolveNode: (playerStat) => playerStat.game,
// Optional size limits, like the connection fields above.
maxSize: 100,
defaultSize: 20,
},
);
builder.prismaObjectField('Player', 'gamesConnection', (t) =>
t.connection({
type: Game,
// Not using t.relatedConnection, so include the selection manually.
select: (args, ctx, nestedSelection) => ({
stats: gameConnectionHelpers.getQuery(args, ctx, nestedSelection),
}),
resolve: (player, args, ctx) =>
// Format the loaded join rows into the connection.
gameConnectionHelpers.resolve(player.stats, args, ctx),
}),
);The helper exposes getQuery (build the Prisma query for the relation), resolve (format a list of loaded rows into a connection), ref (the connection's node ref), and getArgs (below).
prismaConnectionHelpers also covers the case where the edge and connection share the same model and pagination happens directly on a relation to the nodes (even a nested one). Pass the helper's ref as the connection type:
const playerConnectionHelpers = prismaConnectionHelpers(builder, 'Player', {
cursor: 'id',
});
const SelectTeam = builder.prismaObject('Team', {
fields: (t) => ({
name: t.exposeString('name'),
players: t.connection({
type: playerConnectionHelpers.ref,
select: (args, ctx, nestedSelection) => ({
players: playerConnectionHelpers.getQuery(args, ctx, nestedSelection),
}),
resolve: (parent, args, ctx) =>
playerConnectionHelpers.resolve(parent.players, args, ctx),
}),
}),
});Adding arguments
To add arguments to a helper-based connection, the easiest place is the connection field itself. Defining them there lets one helper be shared across fields that don't share the same arguments:
const gameConnectionHelpers = prismaConnectionHelpers(builder, 'PlayerStat', {
cursor: 'id',
select: (nodeSelection) => ({
game: nodeSelection({}),
}),
resolveNode: (playerStat) => playerStat.game,
});
builder.prismaObjectField('Player', 'gamesConnection', (t) =>
t.connection({
type: Game,
args: {
recentFirst: t.arg.boolean(),
},
select: (args, ctx, nestedSelection) => ({
stats: {
...gameConnectionHelpers.getQuery(args, ctx, nestedSelection),
orderBy: {
game: {
playedAt: args.recentFirst ? 'desc' : 'asc',
},
},
},
}),
resolve: (player, args, ctx) =>
gameConnectionHelpers.resolve(player.stats, args, ctx),
}),
);Arguments, ordering, and filtering can also live on the helper itself. Args defined there are available as the second argument of select, and getArgs() adds them to the field:
const gameConnectionHelpers = prismaConnectionHelpers(builder, 'PlayerStat', {
cursor: 'id',
// Arguments for the helper, available as the second argument of `select`.
args: (t) => ({
recentFirst: t.arg.boolean(),
}),
select: (nodeSelection, _args) => ({
game: nodeSelection({}),
}),
query: (args) => ({
// Custom filtering with a where clause.
where: {
game: {
stats: { some: {} },
},
},
// Custom ordering using the args.
orderBy: {
game: {
playedAt: args.recentFirst ? 'desc' : 'asc',
},
},
}),
resolveNode: (playerStat) => playerStat.game,
});
builder.prismaObjectField('Player', 'gamesConnection', (t) =>
t.connection({
type: Game,
// Pull the helper's args onto the field.
args: gameConnectionHelpers.getArgs(),
select: (args, ctx, nestedSelection) => ({
stats: gameConnectionHelpers.getQuery(args, ctx, nestedSelection),
}),
resolve: (player, args, ctx) =>
gameConnectionHelpers.resolve(player.stats, args, ctx),
}),
);Sharing connection objects
By default each connection field generates its own Connection and Edge types. To reuse one across fields, build it up front with builder.connectionObject and pass the resulting ref where a connection field would take its Connection options. It works with t.prismaConnection, t.relatedConnection, and t.connection. Shared edges follow the same pattern with builder.edgeObject.
const PlayerConnection = builder.connectionObject({
// Either a prisma object ref…
type: Player,
// …or a connection helper's ref:
// type: playerConnectionHelpers.ref,
name: 'PlayerConnection',
});
builder.prismaNode('Team', {
id: { field: 'id' },
fields: (t) => ({
name: t.exposeString('name'),
playersConnection: t.relatedConnection(
'players',
{ cursor: 'id' },
// Pass the shared connection ref in place of the Connection options.
PlayerConnection,
),
}),
});Extending connection edges
To expose data from a join table on the edge rather than the node, select the extra join columns in the helper and define the edge fields in the third argument of t.connection. The parent shape for edge fields is inferred from the connection's resolve.
const gameConnectionHelpers = prismaConnectionHelpers(builder, 'PlayerStat', {
cursor: 'id',
select: (nodeSelection) => ({
game: nodeSelection({}),
// Extra fields from the join table, for the edge.
goals: true,
}),
resolveNode: (playerStat) => playerStat.game,
});
builder.prismaObjectFields('Player', (t) => ({
gamesConnection: t.connection(
{
type: Game,
select: (args, ctx, nestedSelection) => ({
stats: gameConnectionHelpers.getQuery(args, ctx, nestedSelection),
select: {
stats: nestedSelection({}, ['edges', 'node']),
},
}),
resolve: (player, args, ctx) =>
gameConnectionHelpers.resolve(player.stats, args, ctx),
},
{},
// Options for the edge object.
{
fields: (edge) => ({
goals: edge.field({
type: 'Int',
// Edge parent is the join row, so goals is available here.
resolve: (stat) => stat.goals,
}),
}),
},
),
}));Total count on shared connection objects
Setting totalCount: true on a prismaConnection or relatedConnection normally adds the totalCount field for you. With a shared connection object it can't, so add the field yourself. The connection's parent carries a totalCount property that is either the number or a function returning it (possibly async):
const PlayerConnection = builder.connectionObject({
type: Player,
name: 'PlayerConnection',
fields: (t) => ({
totalCount: t.int({
resolve: (connection) => {
const { totalCount } = connection as {
totalCount?: number | (() => number | Promise<number>);
};
return typeof totalCount === 'function' ? totalCount() : totalCount;
},
}),
}),
});To add totalCount to every connection, register the field globally with builder.globalConnectionField and declare the Connection shape on the builder so the parent is typed:
export const builder = new SchemaBuilder<{
PrismaTypes: PrismaTypes;
Connection: {
totalCount: number | (() => number | Promise<number>);
};
}>({
plugins: [PrismaPlugin, RelayPlugin],
relayOptions: {},
prisma: {
client: prisma,
dmmf: getDatamodel(),
},
});
builder.globalConnectionField('totalCount', (t) =>
t.int({
nullable: false,
resolve: (parent) =>
typeof parent.totalCount === 'function' ? parent.totalCount() : parent.totalCount,
}),
);Parsing and formatting cursors
parsePrismaCursor and formatPrismaCursor build and read cursors compatible with Prisma connections by hand. Parsing a cursor returns the value from the cursor column, often the id, or an array or object when a compound index backs the cursor. Formatting takes the column value(s) that make up the cursor and produces the opaque cursor string.
import { parsePrismaCursor, formatPrismaCursor } from '@pothos/plugin-prisma';