February 19, 2021
Disclaimer: This post reflects my personal experience as a TypeScript developer.
If you're looking to start a GraphQL project using Node.js, I would recommend using Urql, Nexus, Prisma and GraphQL Codegen.
Obviously, depending on your project and personal preferences, other tools might make more sense; however, I can at least attest to my own developer experience (DX) with the stack above.
If you've gone through the same GraphQL journey as I have: originally defining your schema and resolvers directly with graphql-js and eventually taking the SDL approach offered by graphql-tools; then you've likely also experienced the same pain-points as I have:
Similarly, if you've worked with sequelize or mongoose, you've likely also dealt with the pains of poor TypeScript support, especially when querying for nested entities in your databases.
Thankfully, Nexus + Prisma were built with all of these considerations in mind.
More benefits can be found on the Nexus + Prisma websites
Example of defining your database models with prisma
1datasource db {2 provider = "postgresql"3 url = env("DATABASE_URL")4}56generator client {7 provider = "prisma-client-js"8}910enum UserAllegiance {11 NEUTRAL12 ALLIANCE13 HORDE14}1516model User {17 id Int @id @default(autoincrement())18 email String @unique19 firstName String20 lastName String21 // Example of optional field22 imageUrl String?23 alliance UserAllegiance @default(NEUTRAL)24 // Relation example25 posts Post[]26 createdAt DateTime @default(now())27 updatedAt DateTime @updatedAt2829 // Example of compound index constraint30 @@unique([firstName, lastName])31}3233model Post {34 id Int @id @default(autoincrement())35 title String36 content String37 // Relation example38 author User @relation(fields: [authorId], references: [id])39 authorId Int40}
Example of GraphQL with nexus
1import { GraphQLSchema } from "graphql";2import { arg, makeSchema, mutationType, objectType, queryType } from "nexus";3import { nexusPrisma } from "nexus-plugin-prisma";45const User = objectType({6 name: "User",7 description: "A user of our application",8 definition: (t) => {9 // t.model auto-generates the types/resolvers from our database10 t.model("id");11 t.model("email");12 t.model("firstName");13 t.model("lastName");14 t.model("imageUrl");15 t.model("alliance");16 t.model("posts");17 t.model("createdAt");18 t.model("updatedAt");19 t.nonNull.string("fullName", {20 resolve: (parent, args, ctx, info) => {21 // parent, args and ctx are all extensively typed22 const { firstName, lastName } = parent;2324 return [firstName, lastName].join(" ");25 }26 });27 t.nonNull.number("countPosts", {28 resolve: async (parent, args, ctx, info) => {29 const { id } = parent;30 const { prisma } = ctx;3132 return await prisma.post.count({ where: { authorId: id } });33 }34 });35 }36});3738const Post = objectType({39 name: "Post",40 description: "Comment/Post, made by a User",41 definition: (t) => {42 t.model("id");43 t.model("title");44 t.model("content");45 t.model("author");46 t.model("authorId");47 }48});4950const Query = queryType({51 description: "Root query type",52 definition: (t) => {53 t.nonNull.boolean("ok", { resolve: () => true });54 // t.crud auto-generates types/resolvers for queries/mutations55 t.crud.user();56 t.crud.users();57 t.crud.post();58 t.crud.posts();59 // You can also define custom resolvers60 t.nonNull.number("countPosts", {61 args: {62 // PostWhereInput is generated by t.crud.posts()63 where: arg({ type: "PostWhereInput" })64 },65 resolve: async (parent, args, ctx, info) => {66 // parent, args and ctx are all extensively typed67 const { where } = args;68 const { prisma } = ctx;6970 return await prisma.post.count({ where });71 }72 });73 }74});7576const Mutation = mutationType({77 description: "Root mutation type",78 definition: (t) => {79 t.nonNull.boolean("ok", { resolve: () => true });80 t.crud.createOneUser();81 t.crud.deleteOneUser();82 t.crud.updateOneUser();83 t.crud.upsertOneUser();84 t.crud.createOnePost();85 t.crud.deleteOnePost();86 t.crud.updateOnePost();87 t.crud.upsertOnePost();88 }89});9091// This is just a GraphQLSchema type; use any gql server solution you like92export const schema: GraphQLSchema = makeSchema({93 shouldGenerateArtifacts: true,94 shouldExitAfterGenerateArtifacts: false,95 types: { User, Post, Query, Mutation },96 outputs: {97 schema: "schemaPath",98 typegen: "typingsPath"99 },100 plugins: [101 nexusPrisma({102 experimentalCrud: true,103 outputs: "prismaTypingsPath",104 paginationStrategy: "prisma",105 prismaClient: (ctx) => ctx.prisma106 })107 ],108 sourceTypes: {109 headers: [],110 modules: []111 },112 contextType: {113 module: "@myapp/server/graphql/context",114 export: "GraphQLServerContext",115 alias: "ctx"116 }117});
More docs on the Nexus and Prisma websites
1import { context, GraphQLServerContext } from "@myapp/server/graphql/context";2import { schema } from "@myapp/server/graphql/schema";3import { ApolloServer } from "apollo-server-micro";45export const apolloServer = new ApolloServer({6 context,7 schema8});
For Toastel, I've actually used ApolloClient for my client-side GraphQL needs. However, after having read the docs and trying Urql, I regret not having started with Urql instead.
ApolloClient, along with several custom links, occupies between 30 - 40kB gzipped on my client bundle, compared to Urql's 10 - 20kB.
From an API perspective, Urql also feels remarkably similar to ApolloClient in all of: the GraphQL client, React hooks and caching.
Setting up authentication, retries and caching also felt easier with exchanges and Urql's extensive documentation, whereas Apollo's felt lacking in some places.
Urql provides more comparisons between it and Apollo/Relay on their site.
As we've already improved our TypeScript DX on the server, we might as well do the same on the client.
Let's say that we have a component that renders a list of posts.
1import { Post } from "@prisma/client";2import React, { FC } from "react";3import { useQuery } from "urql";45export interface PostListProps {6 userId: number;7}89export const PostList: FC<PostListProps> = ({ userId }) => {10 const [result] = useQuery<{ posts: Post[] }>({11 query: `12 query GetPosts($userId: Int!) {13 posts(where: { id: $userId }) {14 id15 title16 }17 }18 `,19 variables: { userId }20 });2122 const { data, fetching, error } = result;2324 if (fetching) return <p>Loading...</p>;25 if (error) return <p>Oh no... {error.message}</p>;26 if (!data) return <p>Something went wrong</p>;2728 return (29 <ul>30 {data.posts.map((post) => (31 <li key={post.id}>32 <span>{post.title}</span>33 {/* Oh no! We don't have content, but we passed static analysis! */}34 <span>{post.content}</span>35 </li>36 ))}37 </ul>38 );39};
In the code example above, the type we passed to useQuery
does not match the query string. So we not only had to define our useQuery
type, but also static analysis failed to catch the error we left in.
We can resolve this by using GraphQL Codegen.
1# codegen.yml file23overwrite: true4schema:5 - graphqlSchemaPathHere6documents: "./src/client/graphql/**/*.{fragment,mutation,query}.{ts,graphql}"7generates:8 ./src/client/graphql/generated/graphql.tsx9 plugins:10 - time:11 message: "This file was generated on: "12 format: MMM Do YYYY h:mm:ss a13 - typescript14 - typescript-operations15 - typescript-urql16 ./src/client/graphql/generated/schema.gen.graphql17 plugins:18 - time:19 - schema-ast:20 commentDescriptions: true
1# get-posts.query.graphql file23query GetPosts($userId: Int!) {4 posts(where: { id: $userId }) {5 id6 title7 }8}
1/* ... */23export const PostList: FC<PostListProps> = ({ userId }) => {4 /* Now we'll catch that we're missing a property, without needing to maintain typedefs */5 const [result] = useGetPostsQuery({ variables: { userId } });67/* ... */
With this, not only have we improved our TypeScript DX on the server, but we also achieved a similar DX on the client with an API much like ApolloClient at a fraction of the added bundle size.
Happy coding! -- David Lee