• Blog
  • Talks

Building your Next.js web app using GraphQL

2020-06-17This post is over 2 years old and may now be out of date

(3 minute read)

I generally build my websites in Next.js nowadays. And I almost always use GraphQL as the API surface layer. It was a little tricky to setup initially and so, in this article I will map out all the details in case it's of help to others.

File layout

Firstly, here is roughly how my Next.js app's folders are laid out with respect to the GraphQL component parts:

<Next.js project>
    /pages                  
        /api/graphql.js       <--- GraphQL API endpoint
    /src
        /hooks
            /apollo.js          <--- React Hooks for running queries
        /hoc
            /apollo.js          <--- To enable querying in a page
        /graphql
            /client.js          <--- For browser-side code
            /server.js          <--- For server-side code
            /errorCodes.js      <--- Error codes
            /errors.js          <--- Error-handling utils
            /fragments.js       <--- Query/Mutations result fragments
            /mutations.js       <--- Mutation fragments
            /queries.js         <--- Query fragments
            /resolvers.js       <--- Server-side resolvers
            /typedefs.js        <--- GraphQL Schema    

I will explain each of these files in the following sections.

Server-side setup

I'm a big fan of using GraphQL unions and interfaces to elegantly handle errors and thus, my server-side GraphQL code is setup to facilitate this.

The src/graphql/typedefs.js file contains the schema, for example:

import gql from 'graphql-tag'

export const getTypeDefs = () => gql`
    type ErrorDetails {
    code: String
    message: String
  }
    
  type Error {
    error: ErrorDetails
  }
    
  type Success {
    success: Boolean
  }
    
  type GlobalStats {
    numBlocked: Int!
    numUsers: Int!
  }

  union GlobalStatsResult = GlobalStats | Error
    union DeleteAccountResult = Success | Error
    
    type Mutation {
        deleteAccount: DeleteAccountResult!
    }
    
  type Query {
    getGlobalStats: GlobalStatsResult!
  }
`

// Specify fragment matcher and default resolvers to ensure 
// that our Unions work!

const UNIONS = [
  [ 'GlobalStatsResult', 'GlobalStats' ],
    [ 'DeleteAccountResult', 'Success' ],
]

export const getFragmentMatcherConfig = () => ({
  __schema: {
    types: UNIONS.map(([ ResultTypeDef, SuccessTypeDef ]) => ({
      kind: 'UNION',
      name: ResultTypeDef,
      possibleTypes: [
        {
          name: SuccessTypeDef
        },
        {
          name: 'Error'
        },
      ]
    }))
  }
})


export const getDefaultResolvers = () => ({
  ...UNIONS.reduce((m, [ ResultTypeDef, SuccessTypeDef ]) => {
    m[ResultTypeDef] = {
      __resolveType: ({ error }) => {
        return error ? 'Error' : SuccessTypeDef
      }
    }
    return m
  }, {}),
})

Notice how GlobalStatsResult result is a union type such that at runtime it will either be an Error or GlobalStats object. The getFragmentMatcherConfig() and getDefaultResolvers() functions are there to facilitate this runtime type selection such that callers of the API know what concrete return type they are getting back.

The deleteAccount mutation does not need to return any data so its runtime result will either be an Error or a generic Success object.

The server-side differentiates between explicit and unexpected errors. Explicit errors are ones which the code deliberately returns to indicate a problem, e.g. that the input given was invalid. Unexpected errors and errors which shouldn't really happen. The Error type definition is for handling explicit errors.

All explicit error codes are in src/graphql/errorCodes.js:

// file: src/graphql/errorCodes.js

export const UNKNOWN = 'UNKNOWN'
export const INVALID_INPUT = 'INVALID_INPUT'
export const NOT_LOGGED_IN = 'NOT_LOGGED_IN'

export const messages = {
  [UNKNOWN]: 'There was an unexpected error',
  [INVALID_INPUT]: 'Invalid input',
  [NOT_LOGGED_IN]: 'You must be logged in',
}

In src/graphql/errors.js the createErrorResponse() method is provided for constructing Error response types:

// file: src/graphql/errors.js

import _ from 'lodash'
import * as codes from './errorCodes'

export const createErrorResponse = (code = ERROR_CODES.UNKNOWN, message = '') => {
  return {
    error: {
      __typename: 'ErrorDetails',
      code,
      message: message || codes.messages[code],
    }
  }
}

... // other methods

The src/graphql/resolvers.js file contains the actual query resolvers, for example:

import { getDefaultResolvers } from './typedefs'
import { createErrorResponse } from './errors'
import { UNKNOWN } from './errorCodes'

export default () => {
  return {
    Query: {
      getGlobalStats: async () => {
        try {
          const stats = await getStatsFromDb()

          // type: GlobalStats
          return {
            numBlocked: stats.blocked,
            numUsers: stats.users,
          }
        } catch (err) {
          // type: Error
          return createErrorResponse(UNKNOWN, 'Error fetching stats')
        }
      },
    },
    ...getDefaultResolvers(),
  }
}

API endpoint

In pages/api/graphql.js exists the GraphQL API endpoint handler. This translates the incoming HTTP request into a call to the resolvers specified above. It uses the Apollo server library:

// file: pages/api/graphql.js

import { renderPlaygroundPage } from '@apollographql/graphql-playground-html'
import { convertNodeHttpToRequest, runHttpQuery } from 'apollo-server-core'
import _ from 'lodash'

import { createSchema } from '../../src/graphql/server'

const graphqlOptions = {
  schema: createSchema(),
}

export default async (req, res) => {
  switch (req.method) {
    case 'OPTIONS': {
      res.status(204)
      res.end('')
      break
    }
    case 'GET': {
      res.status(200)
      res.setHeader('Content-Type', 'text/html')
      res.end(renderPlaygroundPage({
        endpoint: '/api/graphql',
        subscriptionEndpoint: '/api/graphql',
      }))
      break
    }
    case 'POST': {
            // Code based on https://github.com/apollographql/apollo-server/blob/master/packages/apollo-server-koa/src/koaApollo.ts
            try {
                const { operationName, variables } = req.body

                const { graphqlResponse, responseInit } = await runHttpQuery([ req, res ], {
                    method: req.method,
                    options: {
                        ...graphqlOptions,
                    },
                    query: req.body,
                    request: convertNodeHttpToRequest(req),
                })

                Object.keys(responseInit.headers).forEach(key => {
                    res.setHeader(key, responseInit.headers[key])
                })

                res.end(graphqlResponse)
            } catch (error) {
                if ('HttpQueryError' !== error.name) {
                    throw error
                }

                if (error.headers) {
                    Object.keys(error.headers).forEach(key => {
                        res.setHeader(key, error.headers[key])
                    })
                }

                res.status(error.statusCode)
                res.end(error.message)
            }

            break
    }
    default: {
      res.status(400)
      res.end('Bad request')
    }
  }
}

export const config = {
  api: {
    bodyParser: {
      sizeLimit: '16mb',
    },
  },
}

Any GET requests to the API will result in the GraphQL playground being served. This is a great tool for testing out queries. The config export is a Next.js convention that specifies custom middleware for the route.

The error handling shown above is concerned with unexpected errors, and these will result in a non-success HTTP status code, as opposed to the explicit errors defined earlier which are returned as query results.

The src/graphql/server.js file contains the createSchema() method used above:

// file: src/graphql/server.js

import { makeExecutableSchema } from 'graphql-tools'

import { getTypeDefs } from './typedefs'
import createResolvers from './resolvers'

export const createSchema = args => {
  return makeExecutableSchema({
    typeDefs: getTypeDefs(),
    resolvers: createResolvers(args),
  })
}

And that's it for the server-side setup. Next, let's look at the client-side.

Client-side setup

On the client-side I use the Apollo client library.

Firstly I create a React higher-order component (HOC) which will wrap each of my pages. This component connects Apollo to my GraphQL endpoint on the server:

// file: src/hoc/apollo.js

import withApolloBase from 'next-with-apollo'
import { ApolloProvider } from '@apollo/react-hooks'

import { createApolloClient } from '../../graphql/client'

export const withApollo = withApolloBase(
  ({ initialState }) => {
    return createApolloClient({
      endpoint: `/api/graphql`,
      initialState,
    })
  },
  {
    render: ({ Page, props }) => {
      return (
        <ApolloProvider client={props.apollo}>
          <Page {...props} />
        </ApolloProvider>
      )
    }
  }
)

As you can see the endpoint it connects to is /api/graphql, which maps exactly to the project file path for the GraphQL API endpoint.

I then have some custom querying hooks which wrap around the default hooks provided by the Apollo client library:

// file: src/hooks/apollo.js
import { useCallback, useState } from 'react'
import { useQuery, useApolloClient } from '@apollo/react-hooks'

import { constructUserFriendlyError, resolveError } from '../graphql/errors'

export const useSafeMutation = (mutation, opts) => {
  const client = useApolloClient()
  const [ result, setResult ] = useState({})

  const fn = useCallback(async args => {
    setResult({
      loading: true
    })

    let ret
    let error

    try {
      ret = await client.mutate({
        mutation,
        ...opts,
        ...args
      })
    } catch (err) {
      error = err
    }

    if (!error) {
      error = resolveError(ret)
    }

    if (error) {
      ret = {
        error: constructUserFriendlyError(error),
      }
    }

    setResult(ret)

    return ret
  }, [ mutation, opts, client ])

  return [ fn, result ]
}

export const useSafeQuery = (query, opts) => {
  const { loading, error, data, ...props } = useQuery(query, {
    fetchPolicy: 'cache-and-network',
    ...opts,
  })

  if ((data || error) && !loading) {
    const resolvedError = resolveError({ error, data })

    if (resolvedError) {
      return { error: constructUserFriendlyError(resolvedError), ...props }
    }
  }

  return { loading, data, ...props }
}

The useSafeQuery() and useSafeMutation() hooks simply ensures that any errors returned by my queries are handled elegantly before returning the result back to the caller.

The resolveError() and constructUserFriendlyError() methods shown above are specified in src/graphql/errors.js. These methods allow callers to handle all errors in the same way, whether they be explicitly generated (e.g. Invalid input) or unexpected/request-related (e.g. HTTP 403 Forbidden):

file: src/graphql/errors.js

export const resolveError = result => {
  const { data, error } = result
  const dataError = _.get(data, `${Object.keys(data || {})[0]}.error`)
  return dataError || error
}

/**
 * Construct user friendly `Error` from given given GraphQL request error.
 *
 * @param  {*} err Error from GraphQL call.
 * @return {Error}
 */
export const constructUserFriendlyError = err => {
  if (Array.isArray(err)) {
    [ err ] = err
  }

  const str = [ err.message ]

  _.get(err, 'networkError.result.errors', []).forEach(e => {
    str.push(constructUserFriendlyError(e).message)
  })

  const e = new Error(str.join('\n'))
  e.code = err.code || _.get(err, 'extensions.exception.code')

  return e
}

Queries and fragments

Query and mutation declarations specified in src/graphql/queries.js and src/graphql/mutations.js, for example:

// file: src/graphql/queries.js

import gql from 'graphql-tag'

import {   GlobalStatsResultFragment } from './fragments'

export const GetGlobalStatsQuery = gql`
  ${GlobalStatsResultFragment}
    
  query GetGlobalStats {
    result: getGlobalStats {
      ...GlobalStatsResultFragment
    }
  }
`

The fragments used above are specified in src/graphql/fragments.js:

// file: src/graphql/fragments.js

import gql from 'graphql-tag'

import {   GlobalStatsResultFragment } from './fragments'

export const GlobalStatsFragment = gql`
  fragment GlobalStatsFragment on GlobalStats {
    numBlocked
    numUsers
  }
`

export const GlobalStatsResultFragment = gql`
  ${GlobalStatsFragment}
  ${ErrorFragment}
    
  fragment GlobalStatsResultFragment on GlobalStatsResult {
    ...on GlobalStats {
      ...GlobalStatsFragment
    }
    ...on Error {
      ...ErrorFragment
    }
  }
`

The final look

Putting everything together, here is how a query might look within one of the pages:

// file: pages/index.js

import React, { useMemo } from 'react'
import _ from 'lodash'

import { withApollo } from '../src/frontend/hoc'
import { useSafeQuery } from '../src/frontend/hooks'
import { GetGlobalStatsQuery } from '../src/graphql/queries'

const HomePage = () => {
  const { data, error, loading } = useSafeQuery(GetGlobalStatsQuery, {
    fetchPolicy: 'cache-and-network',
  })

  const stats = useMemo(() => {
    return {
      numBlocked: _.get(data, 'result.numBlocked', 0),
      numUsers: _.get(data, 'result.numUsers', 0),
    }
  }, [ data ])

  return (
    <div>
        {loading ? <span>Loading</span> : null}
        {error ? <span>Error fetching data!</span> : null}
        {stats.numBlocked}</strong> blocked for <strong>{stats.numUsers}</strong> users
    </div>
  )
}

export default withApollo(HomePage)

Final thoughts

I've been using this setup for a while now and it works very well in production. My only gripe is that there is a lot of boilerplate involved - for example, having to specify queries, mutations and fragments that for the most part simply replicate what's in the type definitions file. Also, I wish error handling via union types was built-in to GraphQL (or perhaps in some elegant higher-level abstraction) as I can't see myself wanting to handle errors differently from now on. It makes total sense to handle request-level errors separately to business logic-level errors.

The only thing I haven't yet tried doing is a GraphQL Subscription as I haven't needed them yet. I don't think a cloud function-like platform would work for those since a persistently running backend is needed. Besides, a websocket connection maybe a better choice for that type of data anyway.

  • Home
  • Blog
  • Talks
  • Github
  • Linked-in
  • Email
  • RSS
© Hiddentao Ltd