Table Of Contents

Important Update

While developing an example project, I have realized that there is a problem with loaders. While I was trying to integrate CSS loader, the page builder stuck.

I do not recommend to use this structure

Use tutorial knowing with this fact.


I am not a big fan of monolithic apps but if the project is small, it is way to go. I love using next.js and hapi.js in my project. One problem is how to using both of them together. SSR (Server Side Rendering) is the most powerful feature of next.js. I had to use a backend server (I know there is a new serverless feature but I haven't use it). If I include my next.js application to hapi.js application then the package.json gets messy due to no separation. We can use 2 different server frontend server and API server.

It is probably better to create a project an API project and a web project to support SSR. But I am proposing another awkward solution! Using lerna to separate backend and frontend! Using lerna we will write our code separately but all scripts will work in sync.

Today I created a test project and It seems fine (It also have some babel related code).

Here is the git repo.

Let's see how we created this.

Creating Lerna Project

Firstly, Install and create our lerna project.

npx lerna init

I created a folder for apps. So main packages can be identified more easy. And created backend and frontend folder.

mkdir apps && mkdir apps/frontend && mkdir apps/backend

We added apps folder. So we need to add folder to search path. Modify lerna.json.

{
  "packages": [
    "packages/*", "apps/*"
  ],
  "version": "0.0.0"
}

Setting up Environment

I always set up environment settings first. So I started creating packages from environment related. You can also use environment variables from console, system or directly but I like '.env' file in root directory.

mkdir packages/env && cd packages/env && npm init --yes

This package's duty is loading environment variables from .env file.

npm install dotenv

Load .env from root directory and export it.

const path = require('path');
require('dotenv').config({
   path:  path.join(process.cwd(), '..', '..', '.env'),
});

module.exports = process.env;

We also need to give a proper name to our package. Just renamed name to "@app/env".

{
  "name": "@app/env",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "dotenv": "^8.1.0"
  }
}

We created our first package!

The next.js app

Go to the frontend folder and init a next.js project. Lerna resolves modules in root packages. We need to move our next.js project to apps/frontend folder.

cd apps/frontend
npx create-next-app
mv my-app/* ./

Let's give a proper name to out frontend project and add some script. I renamed it to "@app/frontend". And added a clean script "clean": "rm -rf ./.next",

{
  "name": "@app/frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "clear": "rm -rf ./.next",
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "9.1.1",
    "react": "16.10.2",
    "react-dom": "16.10.2"
  }
}

This is the important point, we have to export our next app to import in backend app. Otherwise you have to install react, react-dom and other frontend relatated packages in backend too.

const next = require('next');
module.exports = next;

Backend Application

Backand where we configure a lot. Let's create our package and install some dependencies.

In the example repo, I had configuration for babel to use es6 features. I will not go deep in to it. You can easly change it.

cd apps/backend
npm init --yes
npm install @hapi/hapi next --save
# and some anoter babel related things I will not cover them here
# and also nodemon and npm-run-all we need 2 focus our tutorial

Now we can add our lerna packages as dependencies.

  "dependencies": 
    "@app/env": "1.0.0", // because of npm init creates version 1.0.0
    "@app/frontend": "0.1.0",
    "@hapi/hapi": "^18.4.0"
  },

Here I have a modified version of https://github.com/zeit/next.js/tree/canary/examples/custom-server-hapi.

Depending on what you need, just modify and use does not matter much.

I created index.js to to src folder. The file basicly imports next.js and .env values from app@env package. And uses then directly. Lerna does the package linking part.

Importantly, we need to set next.js directory.

Please do not expect this code snippet work directly. I have used babel & babel node to run this code.

import Hapi from '@hapi/hapi';
import path from 'path';
import {
    pathWrapper,
    defaultHandlerWrapper,
    nextHandlerWrapper
} from './utils/next-warapper';
import next from '@app/frontend';
import env from '@app/env';

const init = async () => {

    console.log(process.cwd());
    console.log(env.DOMAIN, env.URL);

    const dev = env.APP_ENVIRONMENT !== 'production';
    const app = next({
        dev,
        dir: path.join(process.cwd(), '..', 'frontend')
    });

    await app.prepare();

    const server = Hapi.server({
        port: 3000,
        host: 'localhost'
    });

    server.route({
        method: 'GET',
        path: '/',
        handler: pathWrapper(app, '/index')

    });

    server.route({
        method: 'GET',
        path: '/b',
        handler: pathWrapper(app, '/b')
    });

    server.route({
        method: 'GET',
        path: '/_next/{p*}' /* next specific routes */,
        handler: nextHandlerWrapper(app)
    });

    server.route({
        method: 'GET',
        path: '/static/{p*}' /* use next to handle static files */,
        handler: nextHandlerWrapper(app)
    });

    server.route({
        method: '*',
        path: '/{p*}' /* catch all route */,
        handler: defaultHandlerWrapper(app)
    });

    await server.start();
    console.log('Server running on %s', server.info.uri);
};

process.on('unhandledRejection', (err) => {
    console.log(err);
    process.exit(1);
});

init().catch((err) => {
    console.error(err.message, err.stack);
});

And below code is the src/utils/next-wrapper.js . It is a copy of repo above.

export const nextHandlerWrapper = app => {
    const handler = app.getRequestHandler();
    return async ({ raw, url }, h) => {
        await handler(raw.req, raw.res, url);
        return h.close;
    }
};

export const defaultHandlerWrapper = app => async ({ raw: { req, res }, url }, h) => {
    const { pathname, query } = url;
    const html = await app.renderToHTML(req, res, pathname, query);
    return h.response(html).code(res.statusCode)
};

export const pathWrapper = (app, pathName, opts) => async (
    { raw, query, params },
    h
) => {
    const html = await app.renderToHTML(
        raw.req,
        raw.res,
        pathName,
        { ...query, ...params },
        opts
    );
    return h.response(html).code(raw.res.statusCode)
};

I also created some scripts related with babel, nodemon and build related. We will focus only start script. Here is the package scripts.

  "scripts": {
    "start": "node ./dist/index.js",
    "dev": "babel-node ./src/index.js",
    "watch": "nodemon",
    "clear": "rm -rf ./dist",
    "build": "babel ./src --out-dir dist",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

Now we are almost done.

Brining all to the table

We created 3 packages. backend depends on 2 packages. Now we will connect them with lerna. Here is my package.json.

{
  "name": "example-app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "b": "lerna bootstrap", // bootstrap is to long ^^
    "dev": "cd apps/backend && npm run watch",
    "build": "lerna run build",
    "clear": "lerna run clean",
    "start": "cd apps/backend && npm run start",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "lerna": "^3.17.0"
  },
  "dependencies": {
    "next": "^9.1.1"
  }
}

The magic is "lerna bootstrap". Lerna bootstrap install dependencies and copies references for proper packages.

When a script starts with "lerna run ...", I will run given script for all packages connected. Build and clean scripts will run for all packages when we call from root.

At the and, we should be able to run our project with "npm run start".

I was a long tutorial but I think, I worth the effort.

  • fullstack