지난 글에 이어 이번 글에서는 Next.js를 이용해서 간단한 웹 사이트를 개발하는 방법을 정리해 보려고 한다.

1. Hello, Next.js!

아주 간단하게 Next.js의 환경설정을 완료한다.

너무 쉽다!

mkdir nextjs-project
cd nextjs-project
yarn init -y && yarn add react react-dom next@beta
// pages/index.js
import React from 'react'

export default () => (
  <div>
    Hello, from Next.js!
  </div>
);
// package.json
// ...
  "scripts": {
    "start": "next"
  }
// ...

이후에 브라우저에서 http://localhost:3000 을 살펴보자.

-p 옵션으로 다른 포트를 지정해줄 수도 있으니까, 필요하신 분들은 다음과 같이 실행하시면 된다.

yarn start -- -p 4000

2. 간단한 디렉토리 구조

.
├── .next             # 빌드 파일. next.config.js 파일 내에서 경로 지정 가능!
├── components        # React 컴포넌트 저장 디렉토리
│   ├── Header.js
│   ├── Footer.js
│   └── Nav.js
├── next.config.js    # Next.js 관련 설정 옵션 파일
├── node_modules
├── package.json
├── pages             # 실질적으로 보여지는 페이지. 언더바(_)가 붙은 파일을 제외하고, 각각의 파일이 하나의 경로(route)에 매핑되는 페이지에 해당.
│   ├── _document.js  # 전체적으로 영향을 미치는, Global 페이지. 생략된 경우 Next.js가 기본 값을 사용.
│   ├── _error.js     # 에러가 났을 때 처리하는 페이지. 생략된 경우 Next.js가 기본 값을 사용.
│   ├── mypage.js     # '/mypage' 경로에 해당하는 페이지.
│   └── index.js      # '/' 경로에 해당하는 페이지.
├── static            # 이미지 등의 에셋 파일들의 저장소. Next.js에 의해 따로 처리되지 않음.
└── yarn.lock

별 게 없다. 하나 하나 살펴보자.

  • .next: 각각의 페이지가 사용자에게 제공되기 위하여 빌드되어 있는 상태의 파일을 저장하고 있는 디렉토리이다. 고이 번들되어 있으므로, 일단은 신경을 쓰지 않아도 된다.
  • components: React 프로젝트에서 사용하는 컴포넌트를 저장해놓는 디렉토리이다. 웹사이트 구축에 필요한 컴포넌트들 (Header, Footer, Nav, MainForm, ImageList 등등)을 저장해놓으면 좋다. 꼭 디렉토리 명이 components 일 필요는 없으나, 관습상 지켜주는 것이 좋다.
  • next.config.js: next 모듈에서 사용하는 설정 옵션(configuration option) 값들. 이후에 많이 사용될 것임!
  • node_modules 와 package.json 은 생략
  • pages: 각각의 파일이 하나의 경로(route)에 해당하여 하나의 페이지를 구성하는 디렉토리. 반드시 디렉토리 명이 pages 여야 됨. 몇 몇 특정 파일을 제외하고는 각각의 파일이 하나의 페이지를 이룸.
  • static: 정적인 파일을 저장해놓는 디렉토리. 페이지 내에서 사용되는 이미지 등으로 구성. next 모듈이 따로 부가적인 처리는 하지 않으며, 있는 그대로 (as is) 다룸.

그러니까, 실질적으로 주의 깊게 볼 부분은 next.config.js 파일과 pages 디렉토리 정도 되겠다.

일단은 next.config.js는 더 밑에서 살펴보도록 하고, 기본 값을 사용하도록 한다 (파일이 없거나, 값을 지정해주지 않으면 next가 기본 값을 사용함).

pages 디렉토리에는 각각의 하나의 페이지에 해당하는 React 컴포넌트들이 위치한다. 이를 컨테이너(container)라고도 부르더라.

각각의 파일이 하나의 페이지이며, 기본적으로 ‘.js를 제외한 파일명’ 이 경로값이 된다.

예를 들어, ‘STH.js’ 라는 파일에 대해서는 /STH 이, ‘Next.js’ 라는 파일에 대해서는 /Next 가 페이지 경로가 된다.

한편 이 규칙에 예외적인 녀석들이 있는데, 다음의 세 종류의 파일이 그것이다.

  • index.js: / 의 경로에 해당하는 파일
  • _document.js: pages 디렉토리 내부에 존재하는 모든 페이지에 Global한 설정 값을 줄 수 있는 파일
  • _error.js: Error 발생 시 제공되는 파일

_document.js 와 _error.js 는 밑에서 자세한 예제를 보자.

3. Next Router

Next.js 는 React Router 를 사용하지 않고, Next.js 에서 개발한 전용 Router 를 사용한다. 서버 사이드 렌더링 등을 포함하여 단일한 API 를 제공하기 위함!

사용법도 굉장히 간단한데, 기본적으로는 다음의 두 가지만 알면 된다.

next/link 과 next/router !

default 로 Link 객체를 임포트해서 사용하는 next/link 는 React 컴포넌트로써 JSX 를 이용한 고레벨 API를 제공하여 라우팅을 지원한다.

// next/link 사용 예시 1: 기본
// components/menu-item.js
import React from 'react';
import Link from 'next/link';

export default ({ children, href }) => (
  <li
    style={{
      marginRight: '10px',
      color: '#ddd',
      padding: '5px'
    }}
  >
    <Link href={href}><a>{children}</a></Link>
  </li>
);
// next/link 사용 예시 2: pushState 대신 replaceState
// components/menu-item.js
import React from 'react';
import Link from 'next/link';

export default ({ children, href }) => (
  <li
    style={{
      marginRight: '10px',
      color: '#ddd',
      padding: '5px'
    }}
  >
    <Link href={href} replace><a>{children}</a></Link>
  </li>
);
// next/link 사용 예시 3: querystring
// components/menu-item.js
import React from 'react';
import Link from 'next/link';

export default ({ children, href, querystrings }) => (
  <li
    style={{
      marginRight: '10px',
      color: '#ddd',
      padding: '5px'
    }}
  >
    <Link href={{
      pathname: href,
      query: {
        querystring1: querystrings[0],
        querystring2: querystrings[1],
        //...
      }
    }}><a>{children}</a></Link>
  </li>
);

next/router

default 로 Router 객체를 임포트해서 사용하는 next/router 는 좀 더 프로그래머에게 제어권이 있는, Programmable API 를 제공해준다.

Router 객체는 다음의 속성, 또는 메서드를 가지고 있다.

  • route: 현재 경로
  • pathname: querystring 을 포함한 전체 경로
  • query: querystring 이 파싱되어 저장된 객체. 기본 값으로 {} 을 갖는다.
  • push(url, as=url): 주어진 url 파라미터에 따라 pushState() 메서드를 호출
  • replace(url, as=url): 주어진 url 파라미터에 따라 replaceState() 메서드를 호출
// next/router 사용 예시 1: 기본
// components/custom-link.js
import React from 'react';
import Router from 'next/router';

export default ({ className, children, href }) => (
  <li className={className}>
    <span onClick={() => Router.push(href)}>{children}</span>
  </li>
);

또한 Router 객체에는 라우팅 도중 발생하는 이벤트에 대해 리스너를 등록할 수도 있다.

이벤트의 종류는 다음과 같다.

  • routeChangeStart(url): 라우팅이 시작될 때 호출
  • routeChangeComplete(url): 라우팅이 끝나면 호출
  • routeChangeError(err, url): 라우팅 도중 에러 발생 시 호출
  • beforeHistoryChange(url): 브라우저 내의 히스토리가 바뀌기 직전에 호출
  • appUpdated(nextRoute): 페이지가 업데이트 되었는 데 새 버전의 어플리케이션이 사용 가능한 경우 호출
// next/router 사용 예시 2: 이벤트 리스너 등록
// pages/about.js

// ~~ ...

Router.onRouteChangeStart = url => {
  console.log(`Route가 ${url}로 변경될 것입니다...!`);
};
Router.onRouterChangeError = (err, url) => {
  if (err) {
    console.error(`Route를 ${url}로 변경 도중 에러가 발생하였습니다...!`);
    console.dir(err);
  }
};

// ... ~~

pushState, replaceState 에 대해 더 알고 싶으신 분들은 MDN 의 브라우저 히스토리에 관한 좋은 문서를 읽어보시기 바랍니다.

4. CSS 추가하기

Next.js 에 CSS 를 추가하는 방법은 여러 가지가 있는데, 서너 개의 프로젝트를 하며 느낀 것이지만 크게는 다음의 세 가지 정도로 간추려질 수 있다.

  • CSS-in-JS
  • HTML 문자열 내에 Global Stylesheet
  • Webpack Loader를 이용한 Stylesheet
// CSS-in-JS; styled-jsx
export default () => (
  <div className='some-container'>
    <a>Some Contents 1!</a>
    <a>Some Contents 2!</a>
    <a>Some Contents 3!</a>

    <style jsx>{`
      .some-container {
        width: 85vw;
        margin: 0 auto;
        height: 100vh;
      }
      .some-container a:hover {
        background-color: blue;
      }
    `}</style>
  </div>
);
// CSS-in-JS; CSS Module
import styles from './styles.css';

export default () => (
  <div className={styles.someContainer}>
    <a className={styles.someA}>Some Contents 1!</a>
    <a className={styles.someA}>Some Contents 2!</a>
    <a className={styles.someA}>Some Contents 3!</a>
  </div>
);
// HTML 문자열 내에 Global Stylesheet; dangerouslySetInnerHTML을 이용
// pages/_document.js
import Document, { Head, Main, NextScript } from 'next/document';

export default class CustomDocument extends Document {
  static getInitialProps ({ renderPage }) {
    const {
      html,
      head,
      errorHtml,
      chunks
    } = renderPage();

    return { html, head, errorHtml, chunks };
  }

  render() {
    return (
      <html>
        <Head>
          <style dangerouslySetInnerHTML={{ __html: `
            @import url(https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css);
            * {
              box-sizing: border-box;
              user-select: none;
            }
            html, body {
              padding: 0;
              margin: 0;
            }
            .peeek-loading {
              overflow: hidden;
              position: absolute;
              top: 0;
              bottom: 0;
              left: 0;
              right: 0;
              height: 100%;
              width: 100%;
            }
            .peeek-loading span {
              position: absolute;
              left: 50%;
              top: 30%;
              transform: translate(-50%, -50%);
              font-size: 2em;
              text-align: center;
              font-family: Arial;
              color: white;
              animation: blinking 2s ease-in-out infinite;
            }
            @keyframes blinking {
              0%, 100% {
                opacity: 1;
              }
              40% {
                opacity: 0;
              }
            }
            .overlay {
              position: fixed;
              width: 100%;
              height: 100%;
              top: 0;
              left: 0;
              z-index: 10000;
            }
          `}} />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </html>
    )
  }
}
// Webpack Loader(css-loader, sass-loader)를 이용한 Global Stylesheet
// pages/_document.js
import Document, { Head, Main, NextScript } from 'next/document';
import './main.scss';

export default class CustomDocument extends Document {
  // ... ~
}

그 외에도 typestyleglamor 같은 재미있는 녀석도 있다. 확실한 건, React 에서 CSS 다루는 방법은 너무나도 다양해서 선택하기 나름이라는 것.

개인적으로는 CSS를 JS에 넣는게 좋은 생각이 아니라고 생각한다.

5. Document 변경하기 및 에러 처리

pages/_document.js 파일을 수정하면 전체적인 페이지에 해당 수정을 적용시킬 수 있다.

// _document.js
// pages/_document.js
import Document, { Head, Main, NextScript } from 'next/document';

export default class CustomDocument extends Document {
  render() {
    return (
      <html>
        <Head>
          {/* Global Head 에 적용시킬 녀석! */}
        </Head>
        <body>
          <Main /> {/* 각각 라우트에 해당하는 페이지가 렌더링 되는 부분 */}
          <NextScript /> {/* Next.js 관련한 자바스크립트 파일 */}
        </body>
      </html>
    )
  }
}

또한 특정 페이지 내에서만 특정한 문서 구조를 갖게 할 수도 있다. render() 메서드에서 반환되는 값이 html 태그를 포함한 전체 문서이면 된다.

만약 에러가 발생한 경우, pages/_error.js 파일을 수정하면 사용자가 원하는 커스텀 에러 처리를 할 수 있다.

// pages/_error.js 파일 내용 (공식 문서에서 가져옴)
import React from 'react'
export default class Error extends React.Component {
  static getInitialProps ({ res, jsonPageRes }) {
    const statusCode = res ? res.statusCode : (jsonPageRes ? jsonPageRes.status : null)
    return { statusCode }
  }

  render () {
    return (
      <p>{
        this.props.statusCode
        ? `An error ${this.props.statusCode} occurred on server`
        : 'An error occurred on client'
      }</p>
    )
  }
}

6. Express 서버에 통합시키기

기존에 있던 Node.js 서버에 통합시키는 것도 아주 쉽다.

여기서는 많이 사용되는 Express.js 프레임워크에 통합시키는 예제를 보자.

// server.js
const express = require('express');
const next = require('next');

const dev = process.env.NODE_ENV === 'development';
const PORT = process.env.PORT || 3000;

const ExpressApp = express();
const NextApp = next({
  dev, // 개발 모드인지 프로덕션인지에 대한 플래그 (true / false)
  quiet: dev,
  dir: 'my-next-app-dir', // Next 프로젝트 파일이 위치한 디렉토리, 기본 값은 현재 디렉토리 값임('.')
  conf: { // next.config.js 에서 사용하는 객체 값
    webpack: {}
  }
})
const NextHandler = NextApp.getRequestHandler();

const CustomRouter = require('./routes');
const NextRouter = express.Router();

NextRouter.get('/route-a', (req, res) => NextApp.render(req, res, 'route-a', Object.assign({}, req.query, req.param)));
NextRouter.get('/route-b', (req, res) => {
  const result = {
    'SOME RESULT': 'FROM DB'
  };
  res.result = result;

  return NextApp.render(req, res, 'route-b', Object.assign({}, req.query, req.param))
});
NextRouter.get('*', (req, res) => NextHandler(req, res));

NextApp
  .prepare()
  .then(() => {
    ExpressApp.use(/* SOME MIDDLEWARE 1 */);
    ExpressApp.use(/* SOME MIDDLEWARE 2 */);
    ExpressApp.use('/', CustomRouter);

    ExpressApp.use('/my-next-app', NextRouter);

    ExpressApp.listen(PORT, err => {
      if (err) throw err;
      console.log(`Listening on ::${PORT}`);
    })
  });
// pages/route-a.js
import React from 'react';

export default class extends React.Component {
  render() {
    return (
      if ()
    );
  }
}
// pages/route-b.js
import React from 'react';

export default class extends React.Component {
  static getInitialProps({req, res}) {
    return {
      resultFromServer: res.result
    }
  }

  render() {
    return (
      <div>Your result from server is {this.props.resultFromServer['SOME RESULT']}</div>
    );
  }
}

7. Redux 추가하기

Redux를 추가하는 것도 그리 어렵지 않다. next-redux-wrapper 모듈의 withRedux 객체를 이용하여 페이지를 래핑해주면 된다.

공식 예시 레포 참조

// store.js
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import thunkMiddleware from 'redux-thunk';

const exampleInitialState = {
  lastUpdate: 0,
  light: false,
  count: 0
};

export const actionTypes = {
  ADD: 'ADD',
  TICK: 'TICK'
};

// REDUCERS
export const reducer = (state = exampleInitialState, action) => {
  switch (action.type) {
    case actionTypes.TICK:
      return Object.assign({}, state, { lastUpdate: action.ts, light: !!action.light });
    case actionTypes.ADD:
      return Object.assign({}, state, {
        count: state.count + 1
      });
    default: return state;
  }
};

// ACTIONS
export const serverRenderClock = (isServer) => dispatch => {
  return dispatch({ type: actionTypes.TICK, light: !isServer, ts: Date.now() });
};

export const startClock = () => dispatch => {
  return setInterval(() => dispatch({ type: actionTypes.TICK, light: true, ts: Date.now() }), 800);
};

export const addCount = () => dispatch => {
  return dispatch({ type: actionTypes.ADD });
};

export const initStore = (initialState = exampleInitialState) => {
  return createStore(reducer, initialState, composeWithDevTools(applyMiddleware(thunkMiddleware)));
};
// components/Page.js
import Link from 'next/link';
import { connect } from 'react-redux';
import Clock from './Clock';
import AddCount from './AddCount';

export default connect(state => state)(({ title, linkTo, lastUpdate, light }) => {
  return (
    <div>
      <h1>{title}</h1>
      <Clock lastUpdate={lastUpdate} light={light} />
      <AddCount />
      <nav>
        <Link href={linkTo}><a>Navigate</a></Link>
      </nav>
    </div>
  )
})
//pages/index.js
import React from 'react';
import { bindActionCreators } from 'redux';
import { initStore, startClock, addCount, serverRenderClock } from '../store';
import withRedux from 'next-redux-wrapper';
import Page from '../components/Page';

class Counter extends React.Component {
  static getInitialProps ({ store, isServer }) {
    store.dispatch(serverRenderClock(isServer))
    store.dispatch(addCount())

    return { isServer };
  }

  componentDidMount () {
    this.timer = this.props.startClock();
  }

  componentWillUnmount () {
    clearInterval(this.timer);
  }

  render () {
    return (
      <Page title='Index Page' linkTo='/other' />
    );
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    addCount: bindActionCreators(addCount, dispatch),
    startClock: bindActionCreators(startClock, dispatch)
  };
}

export default withRedux(initStore, null, mapDispatchToProps)(Counter);

대강 이런 느낌이다. connect() 대신 withRedux()를 이용해서 컴포넌트를 래핑한다.

자세한 예제는 공식 예제 레포에서 받으시길 바람.

8. Custom Configuration

next.config.js 파일을 수정함으로써 Next 모듈이 사용하는 디테일 한 설정값을 줄 수 있다.

// next.config.js 파일
import webpack from 'webpack';
module.exports = {
  distDir: 'myNextAppBuild', // 빌드 파일을 저장할 디렉토리 지정. 기본 값은 .next
  webpack: (config, { dev }) => { // Webpack 설정값 (webpack.config.js)
    config.plugins.push(new webpack.optimize.UglifyJsPlugin({
      compress: { warnings: false }
    }));
    return config;
  },
  webpackDevMiddleware: (config) => { // 개발 모드에서 사용되는 Webpack Dev Middleware에 사용되는 설정 값
    // BLAHBLAH!
    return config;
  },
  exportPathMap: () => ({ // Next Export 에서 사용하는 값
    "/": { page: "/" },
    "/about": { page: "/about" },
    "/p/hello-nextjs": { page: "/post", query: { title: "hello-nextjs" } },
    "/p/learn-nextjs": { page: "/post", query: { title: "learn-nextjs" } },
    "/p/deploy-nextjs": { page: "/post", query: { title: "deploy-nextjs" } }
  })
};

9. Next Export

next.config.js 파일의 exportPathMap 값을 수정하여 각각에 해당하는 페이지 출력 값을 매핑시켜주면 정적 페이지 생성을 할 수도 있다.

위의 예시 참조.

이후, next build로 빌드를 한 다음에, next export로 빌드한 파일을 정적 페이지로 생성한다.