Post

Next.js with Express.js and GraphQL

Next.js with Express.js and GraphQL

Setting up a monolith with Next.js as frontend with Express.js and GraphQL as backend


Disclaimer:

  • This whole setup was done in about a couple of hours on day-1 for a new project. The aim was to have a monolith up and running quickly without ignoring the high-level separation of concerns and intuitive folder structure. Other architectural concerns definitely took a back stage
  • It is NOT a step by step guide for you to follow in order to create a similar setup
  • My intention is to highlight the key challenges I faced in this short exercise, such as structuring the project, integrating Express middleware in Next.js API routes, deploying to Vercel, modularizing GraphQL schemas and resolvers for scalability, and keeping frontend and backend code separated while maintaining compatibility with Next.js conventions.

Next.js as frontend with Express.js and GraphQL backend

I was starting a new side project, which would have quite a few public facing pages. For SSR and SSG, I chose Next.js. But I was also aware of the risk of choosing Next.js for the wrong reasons, and wanted to see if there is an option to merge it with an appropriate backend stack. After exploring a few options, I settled with Express.js and GraphQL as the backend for my application.

Step 1: Project initiation with Next.js

  • From inside the project folder npx create-next-app@latest .
  • Opted to use the Pages router instead of the App router by creating pages/index.tsx
    1
    2
    3
    
    export default function Page() {
      return <h1>Hello, Next.js!</h1>
    }
    

Step 2: Introducing Express.js

  • npm install express
  • Created a server.ts file at the root, and
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    ..
    ..
    const dev = process.env.NODE_ENV !== 'production';
    const app = next({ dev });
    ..
    ..
    app.prepare().then(() => {
      const server = express();
    
      server.get('/api/something', (req, res) => {
    ..
    ..
    
  • Set Express.js to handle the dynamic root handling
    1
    2
    3
    4
    5
    6
    
    server.get('/collection/:id', (req, res) => {
      const actualPage = '/collection/[id]';
      const queryParams = { id: req.params.id };
    
      app.render(req, res, actualPage, queryParams);
    });
    
  • Updated the scripts in package.json
    1
    2
    3
    4
    5
    
    "scripts": {
      "dev": "node server.js",
      "build": "next build",
      "start": "NODE_ENV=production node server.js"
    }
    

At that time, the folder structure approximately looked like this:

1
2
3
4
5
6
7
8
9
  /project_root
  ├── /src
      ├── /pages      # Page Router
      ├── /components # Shared components
      ├── /public     # Static files served by Next.js
  ├── /node_modules
  ├── package.json
  ├── server.js       # Custom Next.js server handling Express.js
  └── ...

Unfortunately, I missed an obvious problem with this. Although, it worked fine on local with npm run dev and did run both Next and Express servers, when I deployed the app on Vercel, the frontend came up fine but the Express.js was completely ignored. Vercel doesn’t support custom Node.js servers like Express.js directly in the same way it does on local environments. Vercel is designed to work with serverless functions, and therefore, it won’t run a custom server.js as it would locally.

While I fixed this, I also wanted to introduce GraphQL.

Step 3: Vercel fix and adding GraphQL

  • Removed server.ts
  • Installed graphql
    1
    2
    3
    
    npm install graphql express-graphql graphql-tag
    npm install @graphql-tools/schema @graphql-tools/merge
    npm install --save-dev @types/node @types/express @types/graphql
    
  • Reverted the package.json changes
    1
    2
    3
    4
    5
    
    "scripts": {
      "dev": "next dev",
      "build": "next build",
      "start": "next start"
    }
    
  • Created a helper function to allow Next.js to run Express middleware within API routes. This is necessary because Next.js API routes don’t natively support Express middleware out of the box.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    // lib/runMiddleware.js
    
    export function runMiddleware(req, res, fn) {
      return new Promise((resolve, reject) => {
        fn(req, res, (result) => {
          if (result instanceof Error) {
            return reject(result);
          }
    
          return resolve(result);
        });
      });
    }
    
  • Moved setting up Express.js to a Vercel serverless function by adding /pages/api/index.ts to handle the GraphQL
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    
    import { buildSchema } from 'graphql';
    import { graphqlHTTP } from 'express-graphql';
    import express from 'express';
    import { runMiddleware } from '../../../lib/runMiddleware';
    
    const app = express(); // Express instance
    
    // GraphQL schema
    const schema = buildSchema(`
      type Query {
        hello: String
      }
    `);
    
    // GraphQL root resolver
    const root = {
      hello: () => 'Hello from GraphQL!',
    };
    
    app.use(
      '/api',
      graphqlHTTP({
        schema: schema,
        rootValue: root,
        graphiql: process.env.NODE_ENV === 'development'
      }))
    );
    
    // Middleware wrapper for serverless function execution
    export default async function handler(req, res) {
      await runMiddleware(req, res, app);
    }
    
    export const config = {
      api: {
        bodyParser: false,
      },
    };
    

Now, the Vercel deployment worked fine and https://www.predeect.com/api responded as expected. The folder structure somewhat looked like this:

1
2
3
4
5
6
7
8
9
10
11
/project_root
  ├── /src
      ├── /pages          # Page Router files
          ├── /api
              ├── index.ts
      ├── /public         # Static files
  ├── /lib
      ├── runMiddleware.js # Helper to run Express as middleware
  ├── /node_modules
  ├── package.json
  └── ...

But more problems. The GraphQL root resolver being inside /pages/api/index.ts, wasn’t really scalable. So, I wanted to structure and modularize the GraphQL resolvers and schema for better separation of concerns. This would mean separating schemas, resolvers, and business logic into different modules and keeping API route files lightweight, focusing mainly on importing the required resolvers and schemas.

Step 4: Organizing GraphQL resolvers and schema

  • Added a dedicated GraphQL router under /src/graphql/index.ts:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    import { makeExecutableSchema } from '@graphql-tools/schema';
    import { mergeResolvers, mergeTypeDefs } from '@graphql-tools/merge';
    import { userTypeDefs } from './schemas/userSchema';
    import { postTypeDefs } from './schemas/postSchema';
    import { userResolvers } from './resolvers/userResolver';
    import { postResolvers } from './resolvers/postResolver';
    import { createContext } from './context';
    
    // Merge the type definitions and resolvers
    const typeDefs = mergeTypeDefs([userTypeDefs, postTypeDefs]);
    const resolvers = mergeResolvers([userResolvers, postResolvers]);
    
    // Create the executable schema
    export const schema = makeExecutableSchema({
      typeDefs,
      resolvers,
    });
    
    // Context to be passed to each request
    export const context = createContext();
    
  • Updated the Next API route to ensure correct context handling
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    // src/pages/api/index.tsx
    
    import { schema, context } from '../../graphql';
    import { graphqlHTTP } from 'express-graphql';
    import express from 'express';
    import { NextApiRequest, NextApiResponse } from 'next';
    import { runMiddleware } from '../../../lib/runMiddleware';
    
    const app = express();
    
    // GraphQL middleware with context
    app.use(
      '/api',
      graphqlHTTP({
        schema,
        context,
        graphiql: process.env.NODE_ENV === 'development',
      })
    );
    
    // Middleware wrapper for running Express within the Next.js API route
    export default async function handler(req: NextApiRequest, res: NextApiResponse) {
      await runMiddleware(req, res, app);
    }
    
    export const config = {
      api: {
        bodyParser: false, // Disable body parser to allow GraphQL to handle it
      },
    };
    
  • The /lib folder moved in parallel with /src under the project root, and a new /datasources folder added to define the data sources.
  • Data Sources: Created userAPI.ts and postAPI.ts to manage user and post-related data.
  • GraphQL Context: The createContext() function now includes userAPI and postAPI
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    // /src/graphql/context.ts
    
    import { UserAPI } from '../../datasources/userAPI';
    import { PostAPI } from '../../datasources/postAPI';
    
    export interface GraphQLContext {
      dataSources: {
        userAPI: UserAPI;
        postAPI: PostAPI;
      };
    }
    
    export const createContext = (): GraphQLContext => {
      return {
        dataSources: {
          userAPI: new UserAPI(),
          postAPI: new PostAPI(),
        },
      };
    };
    
  • The reorganized folder structure:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    /project_root
      ├── /src
          ├── /graphql
              ├── /resolvers         # All resolver logic
                  ├── userResolver.ts
                  ├── postResolver.ts
              ├── /schemas           # GraphQL schemas (by feature)
                  ├── userSchema.ts
                  ├── postSchema.ts
              ├── /types.ts          # Type definitions
              ├── /context.ts        # GraphQL context definitions
              ├── index.ts           # Combines schemas and resolvers into a single executable schema
          ├── /pages
              ├── /api
                  ├── index.tsx      # API route for GraphQL
      ├── /lib
          ├── runMiddleware.ts       # Helper to run middleware in Next.js API routes
      ├── /datasources
          ├── userAPI.ts             # Datasource logic for user-related data
          ├── postAPI.ts             # Datasource logic for post-related data
      ├── package.json
      ├── tsconfig.json
      └── ...
    
  • For a minute, I thought about my choice to keep the /lib and ‘/datasources’ outside /src. It had its pros and cons. But finally I decided to keep everything related to the application inside /src for consistency and organization, as the otherwise possible modularization for “reuse” across projects wasn’t a good reason yet.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    /project_root
    ├── /src
        ├── /graphql
            ├── /resolvers
            ├── /schemas
            ├── /types.ts
            ├── /context.ts
            ├── index.ts
        ├── /pages
            ├── /api
                ├── index.tsx
        ├── /datasources       # Moved inside src
            ├── userAPI.ts
            ├── postAPI.ts
        ├── /lib               # Moved inside src
            ├── runMiddleware.ts
    ├── /public
    ├── package.json
    ├── tsconfig.json
    

Next question that popped up was - what about the clear separation between the frontend and backend code?

Step 5: Separating frontend/backend

  • It was a bit silly to try the following structure:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    /project_root
    ├── /src
        ├── /frontend
            ├── /components      # Moved layout.tsx here
            ├── /pages           # Next.js pages for the frontend
            ├── /styles          # Moved globals.css here
        ├── /backend
            ├── /graphql         # GraphQL schemas, resolvers, types
            ├── /datasources     # Data sources (e.g., DB queries, API calls)
            ├── /lib             # Utilities or middleware
            ├── /api             # Next.js API routes
    ├── /public
        ├── /fonts               
        ├── favicon.ico          
    ├── package.json
    ├── tsconfig.json
    

Next.js expected the /pages folder to be at the root of the /src directory (or at the root of the project if there’s no /src). In my case, I had moved the /pages folder under /src/frontend/pages/, which is not where Next.js automatically looks for the pages. Naturally, the home page showed 404.

Step 6 - The final structure

  • Moved /pages back to /src/pages but their sole purpose would be to import and render the actual content from the /frontend folder. The /src/frontend/pages would hold all my frontend components and logic. This approach allowed for everything to be organized without breaking Next.js conventions.
    1
    2
    3
    4
    5
    6
    7
    
    // src/pages/index.tsx
    
    import Home from '../frontend/pages/Home';
    
    export default function HomePage() {
      return <Home />;
    }
    
  • Moved the API routes to /src/pages/api from /src/backend/api
  • After quite a few small fixes and tweaks, it finally shaped up to:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    
    /project_root
    ├── /public
    │   ├── /fonts
    │   ├── favicon.ico
    │   └── logo.png
    ├── /src
    │   ├── /backend
    │   │   ├── /datasources
    │   │   │   ├── postAPI.ts
    │   │   │   └── userAPI.ts
    │   │   ├── /graphql
    │   │   │   ├── /resolvers
    │   │   │   │   ├── postResolver.ts
    │   │   │   │   └── userResolver.ts
    │   │   │   ├── /schemas
    │   │   │   │   ├── baseSchema.ts
    │   │   │   │   ├── postSchema.ts
    │   │   │   │   └── userSchema.ts
    │   │   │   ├── context.ts
    │   │   │   ├── index.ts
    │   │   │   └── types.ts
    │   │   └── /lib
    │   │       └── runMiddleware.js
    │   ├── /frontend
    │   │   ├── /components
    │   │   │   └── layout.tsx
    │   │   ├── /pages
    │   │   │   └── Home.tsx
    │   │   └── /styles
    │   │       └── globals.css
    │   ├── /pages
    │   │   ├── /api
    │   │   │   └── index.ts
    │   │   ├── _app.tsx
    │   │   └── index.tsx
    │   └── next-env.d.ts
    ├── package.json
    └── tsconfig.json
    

It was satisfactory to look at the overall structure and how iteratively I arrived at it. At this point, the next plan was to hook in:

  • Databases (thinking of Redis and PostgreSQL)
  • GraphQL Code Generator
  • Testing libraries (unit, integration, and e2e) for both, the frontend and the backend
  • UI component library (maybe Adobe Spectrum)

But all that would be much easier than the challenges above with combining Next.js, Express.js, GraphQL and setting up a clean file/folder structure for the Predeect monolith.

Concluding summary:

In this post, I shared my journey of integrating Next.js with Express.js and GraphQL, facing obstacles with serverless limitations and routing mismatches. The biggest challenges were balancing server-side logic while maintaining serverless compatibility and separation of frontend/backend source code while keeping the Next.js conventions. Moving forward, improving SSR and handling more complex GraphQL queries will likely pose additional hurdles, but hopefully, this setup would provide a flexible foundation for further scaling.

This post is licensed under CC BY 4.0 by the author.