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.
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, and1 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
inpackage.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
changes1 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 GraphQL1 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
andpostAPI.ts
to manage user and post-related data. - GraphQL Context: The
createContext()
function now includesuserAPI
andpostAPI
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.