Node.js in 2018: Full stack tutorial with Koa, React, Redux, Sagas and MongoDB

We build professional apps for Android, iOS and mobile web.
Have a look

Introduction

With so much choice in 2018 for web development, it can get quickly get really confusing trying to understand what all these terms mean and how each part works together to build a web app. It may seem like the days of simply using HTML, CSS and JS to build a web app are over, but once you understand the ideas here you’ll see it’s only become more powerful.

The aim of this tutorial is to practically show you my favourite tech stack to use day to day as a Software Developer at jtribe. This will be a step-by-step guide on how to set up and build a basic application which goes full stack from the backend to the frontend, database and all! Even going as far deploying your application into the real world 🙂

DISCLAIMER: This is definitely overkill for a simple Todo app, but all of the concepts learnt here can be scaled up to very large applications and it doesn’t get much more complex than what you see here.

If you want to skip to the end, all the code is available at:
https://github.com/rhysdavies1994/koa-react-todo-tutorial

Tech stack

Backend Node.js using Koa for our server
MongoDB - NoSQL Database for persisting our data
Mongoose - Database layer for MongoDB
Now - Deploying our app

Frontend React - View layer
Redux - State management
Redux Sagas - Side effects and Async
Bulma - Lightweight CSS framework based on flex

What we’ll be building

A basic Todo app with CRUD features.

CRUD operations:

  • Create todo and store it in a list of data
  • Read todos from a list of data
  • Update todo to mark it as complete
  • Delete todo

Let’s get started

Step 1: Ensure you’ve got Node.js and NPM setup

Easiest way to install node Go to https://nodejs.org/en/ and click download current version, run the installer.

Advanced setup To have more control over your node versions for different projects,
I’d recommend setting up something called Node Version Manager (NVM).

  1. Follow installation instructions at https://github.com/creationix/nvm
  2. nvm install node
  3. nvm use node

After either of these setup options you should have access to two new commands:

  • node: Node runtime to run JavaScript on your computer/server.
  • npm: Node Package Manager to install and manage JavaScript packages

Step 2: Set up Koa backend

Koa is super simple to set up, but to save some time we are going to use a basic boilerplate. There is a nice one called Koalerplate, it comes with some great configuration that every project can make use of, I also just like the name.

We’re going to create an overarching project folder, then have a backend and frontend directory to keep them separate:

mkdir my-project-name
cd my-project-name

Then while inside your project directory,
follow the instructions at: https://github.com/dbalas/koalerplate, or TLDR;

git clone https://github.com/dbalas/koalerplate.git backend
cd backend
mv .env.sample .env
npm install
npm run dev

Now you should be able to open up http://localhost:3000/v1/users and be greeted with an empty object { }. The backend is alive!

Structure of our backend

  • package.json defines your project from nodes perspective, change project details and dependencies here.
  • index.js is your entry point to the app.
  • server.js is where koa has been defined and configured
  • routes/index.js is where your routes are defined
  • routes/.js is where you’ll set up a route
  • controllers/.js is where you’ll define the logic for your routes
  • models is where you’ll configure your database models
  • normally we’d have a views folder for MVC pattern, but React will be take care of our view layer

Next we’ll continue building out our Backend, rather than switching into setting up the Frontend.

Step 3: Communicating to our Database

Create a MongoDB instance

We’ll be using mLab in this tutorial so we can get a free database with up to 500mb storage.

  1. Head to https://mlab.com and sign up for an account.
  2. Once you are logged in, create a new MongoDB deployment;

  3. After you’ve created your Mongo database, create a database user to have access to it

Set up Mongoose

We’ll be using the Mongoose library to easily communicate with our MongoDB instance.

  1. Add the mongoose package to our backend: npm install mongoose --save
  2. Connect mongoose to our server:
    /* server.js */
    // ... Other required modules
    const mongoose = require('mongoose');

    // ... Koa code
    mongoose.connect('mongodb://<dbuser>:<dbpassword>@eg12345.mlab.com:12345/my_database')
    module.exports = app
  1. Create our Todo Model:
    /* models/todo.js */
    const mongoose = require('mongoose')

    // Declare Schema
    const TodoSchema = new mongoose.Schema(
      {
        description: { type: String },
        done: { type: Boolean },
      }, 
      { timestamps: true }
    );

    // Declare Model to mongoose with Schema
    const Todo = mongoose.model('Todo', TodoSchema)

    // Export Model to be used in Node
    module.exports = mongoose.model('Todo')

NOTE: adding option { timestamps: true } automatically sets createdAt and updatedAt values on the model in your mongo database.

Build the Todos Route

  1. Create routes/todos.js, we’ll get to the controller methods in a second
    const Router = require('koa-router')
    const router = new Router()
    const Ctrl = require('../controllers/todos')

    router.get('/', Ctrl.findAll)
    router.post('/', Ctrl.create)
    router.post('/:id', Ctrl.update)
    router.delete('/:id', Ctrl.destroy)

    module.exports = router.routes()
  1. Configure todos route on index router
    /* routes/index.js */
    module.exports = (router) => {
      router.prefix('/v1')
      router.use('/todos', require('./todos'))
    }

Implement CRUD operations in Todos controller

Create controllers/todos.js

    const Todo = require('../models/todo')

    async function findAll (ctx) {
      // Fetch all Todo's from the database and return as payload
      const todos = await Todo.find({})
      ctx.body = todos
    }

    async function create (ctx) {
      // Create New Todo from payload sent and save to database
      const newTodo = new Todo(ctx.request.body)
      const savedTodo = await newTodo.save()
      ctx.body = savedTodo
    }

    async function destroy (ctx) {
      // Get id from url parameters and find Todo in database
      const id = ctx.params.id
      const todo = await Todo.findById(id)

      // Delete todo from database and return deleted object as reference
      const deletedTodo = await todo.remove()
      ctx.body = deletedTodo
    }

    async function update (ctx) {
      // Find Todo based on id, then toggle done on/off
      const id = ctx.params.id
      const todo = await Todo.findById(id)
      todo.done = !todo.done

      // Update todo in database
      const updatedTodo = await todo.save()
      ctx.body = updatedTodo
    }

    module.exports = {
      findAll,
      create,
      destroy,
      update
    }

Now we have a functional backend with CRUD operations talking to our database
Examples:

    GET /v1/todos -> Returns all todos in database
    POST /v1/todos -> Create new todo
    POST /v1/todos/1 -> Update todo with id of 1
    DELETE /v1/todos/1 -> Delete todo with id of 1

Step 4: Set up React Frontend

Generate project using Create React App

We are going to use create-react-app to instantiate our React frontend. CRA comes with great build configuration using webpack with hot reloading and a bunch of other goodness straight out of the box. You don’t even need to see this config unless you want to eject.

More info at: https://github.com/facebook/create-react-app

Inside of our main project directory (not backend):

    npx create-react-app frontend --use-npm
    cd frontend
    npm start

Then you should see our basic frontend load up inside your browser on http://localhost:3000

Get the frontend to talk to the backend in development

  1. Change backend to be on a different port. Right now our frontend is on port 3000 and so is our backend, let’s change our backend to port 4000 so they don’t conflict:
    /* backend/index.js */
    // ...
    const port = process.env.PORT || 4000
  1. Add Koa static to backend to serve up compiled React app:
    /* Inside backend folder */
    npm install koa-static --save

    /* backend/server.js */
    // ... below app.use(router.routes()) so api has higher priority
    app.use(require('koa-static')('./build'))
  1. Add proxy in frontend package.json and improve build script:
    /* frontend/package.json */
    // ... scripts : { ...
      "build": "rm -rf ./build && react-scripts build && rm -rf ../backend/build && mv ./build ../backend/build"
      },
      "proxy": "http://localhost:4000"
    }
  1. Optional: Create overarching project to run backend and frontend concurrently:
    /* from main folder directory */
    npm init // fill out details of your project
    npm install concurrently --save-dev

    // Add start, backend and frontend scripts to package.json
    /* package.json */
    {
      "name": "todo-tutorial",
      "version": "0.1.0",
      "private": true,
      "dependencies": {},
      "scripts": {
        "start": "concurrently --kill-others \"npm run backend\" \"npm run frontend\"",
        "backend": "cd backend && npm run dev",
        "frontend": "cd frontend && npm start",
        "build": "cd frontend && npm run build"
      },
      "devDependencies": {
        "concurrently": "^3.5.1"
      }
    }

Now during development, we can just run npm start from our main project directory to start up both the backend and frontend development servers. When we want to deploy to production we can use npm run build to compile the frontend and be served by our backend with its API.

Step 5: Building the UI for our App

We’re only going to have one page for this Todo app, so to get started let’s clear out the template that create-react-app gives us and put in our own Todos component.

    /* Inside of frontend directory */
    cd src
    touch Todos.js

    /* frontend/src/App.js */
    import React, { Component } from 'react'
    import './App.css'
    import Todos from './Todos'

    class App extends Component {
      render() {
        return (
          <div className="App">
            <Todos />
          </div>
        )
      }
    }

    export default App

    /* frontend/src/Todos.js */
    import React, { Component } from 'react'

    class Todos extends Component {
      render () {
        return (
          <div>Hello</div>
        )
      }
    }

    export default Todos

Next;

  • We’ll add Bulma to help style our Todos component
  • Get our component to load the Todo’s from our backend
  • and render them in a nice looking layout.
    /* Inside frontend directory*/
    npm install bulma --save

    /* frontend/src/Todos.js */
    import React, { Component } from 'react'
    import 'bulma/css/bulma.css'

    const Todo = ({ todo, id }) => (
      <div className="box todo-item level is-mobile">
        <div className="level-left">
          <label className={`level-item todo-description ${todo.done && 'completed'}`}>
            <input className="checkbox" type="checkbox"/>
            <span>{todo.description}</span>
          </label>
        </div>
        <div className="level-right">
          <a className="delete level-item" onClick={() => {}}>Delete</a>
        </div>
      </div>
    )

    class Todos extends Component {
      state = {
        newTodo: '',
        todos: [],
        error: '',
        isLoading: false
      }

      componentDidMount() {
        this.fetchTodos()
      }

      fetchTodos () {
        this.setState({ isLoading: true })

        // HTTP GET Request to our backend api and load into state
        fetch('v1/todos')
          .then((res) => res.json())
          .then(todos => this.setState({ isLoading: false, todos }))
          .catch((error) => this.setState({ error: error.message }))
      }

      addTodo (event) {
        event.preventDefault() // Prevent form from reloading page
        const { newTodo, todos } = this.state

        if(newTodo) {
          this.setState({
            newTodo: '',
            todos: todos.concat({ description: newTodo, done: false })
          })
        }
      }

      render() {
        let { todos, newTodo, isLoading, error } = this.state

        const total = todos.length
        const complete = todos.filter((todo) => todo.done).length
        const incomplete = todos.filter((todo) => !todo.done).length

        return (
          <section className="section full-column">
            <h1 className="title white">Todos</h1>
            <div className="error">{error}</div>

            <form className="form" onSubmit={this.addTodo.bind(this)}>
              <div className="field has-addons" style={{ justifyContent: 'center' }}>
                <div className="control">
                  <input className="input"
                         value={newTodo}
                         placeholder="New todo"
                         onChange={(e) => this.setState({ newTodo: e.target.value })}/>
                </div>

                <div className="control">
                  <button className={`button is-success ${isLoading && "is-loading"}`}
                          disabled={isLoading}>Add</button>
                </div>
              </div>
            </form>

            <div className="container todo-list">
              {todos.map((todo) => <Todo key={todo._id} id={todo._id} todo={todo}/> )}
              <div className="white">
                Total: {total}  , Complete: {complete} , Incomplete: {incomplete}
              </div>
            </div>
          </section>
        );
      }
    }

    export default Todos

    /* frontend/src/App.css */

    html { background-color: #222222; }
    body { background: cornflowerblue; }
    .todo-item { text-align: left; }
    .error { color: crimson; }
    .white { color: white !important; }
    .checkbox { margin-right: 10px; }
    .completed { text-decoration-line: line-through; }
    .todo-description { cursor: pointer; }
    .todo-list { max-width: 400px !important; }
    .form { margin-bottom: 50px;}

    .App {
      text-align: center;
      min-height: 100vh;
      display: flex;
      flex-direction: column;
    }

    .full-column {
      display: flex;
      flex: 1;
      flex-direction: column;
    }

Step 6: Moving State Management to Redux

Install required packages in frontend: npm install redux react-redux --``save

Setup Redux in React

    /* frontend/src/index.js */
    // ...
    import { Provider } from 'react-redux'
    import { createStore, applyMiddleware } from 'redux'
    import rootReducer, { DEFAULT_STATE } from './reducers'

    const store = createStore(rootReducer, DEFAULT_STATE)

    ReactDOM.render((
      <Provider store={store}>
        <App />
      </Provider>
    ), document.getElementById('root'))

Create root reducer

    /* frontend/src/reducers/index.js */
    import { combineReducers } from 'redux'
    import todos, { TODOS_DEFAULT_STATE } from './todos'

    const todoApp = combineReducers({
      todos
    })

    export const DEFAULT_STATE = {
      todos: TODOS_DEFAULT_STATE
    }

    export default todoApp

Create Redux Action Types and Creators for Todos

    /* frontend/src/actions/todos.js */
    // action types
    export const ADD_TODO = 'ADD_TODO'
    export const ADD_TODO_SUCCESS = 'ADD_TODO_SUCCESS'
    export const TODOS_FAILURE = 'TODOS_FAILURE'
    export const TOGGLE_TODO = 'TOGGLE_TODO'
    export const DELETE_TODO = 'DELETE_TODO'
    export const LOADED_TODOS = 'LOADED_TODOS'
    export const FETCH_TODOS = 'FETCH_TODOS'

    // action creators
    export function addTodo(todo) {
      return { type: ADD_TODO, todo }
    }

    export function addTodoSuccess(todo) {
      return { type: ADD_TODO_SUCCESS, todo }
    }

    export function todosFailure(error) {
      return { type: TODOS_FAILURE, error }
    }

    export function toggleTodo(id) {
      return { type: TOGGLE_TODO, id }
    }

    export function deleteTodo(id) {
      return { type: DELETE_TODO, id }
    }

    export function loadedTodos(todos) {
      return { type: LOADED_TODOS, todos }
    }

    export function fetchTodos() {
      return { type: FETCH_TODOS }
    }

Create Todos Reducer

    /* frontend/src/reducers/todos.js */
    import {
      ADD_TODO,
      ADD_TODO_SUCCESS,
      TODOS_FAILURE,
      TOGGLE_TODO,
      DELETE_TODO,
      LOADED_TODOS,
      FETCH_TODOS
    } from '../actions/todos'

    export const TODOS_DEFAULT_STATE = {
      loading: false,
      saving: false,
      error: '',
      items: []
    }

    export default function todos (state = TODOS_DEFAULT_STATE, action) {
      switch (action.type) {
        case LOADED_TODOS:
          return {...state, items: action.todos, loading: false}

        case FETCH_TODOS: {
          return {...state, loading: true}
        }

        case ADD_TODO:
          return {...state, saving: true}

        case ADD_TODO_SUCCESS:
          return {
            ...state,
            items: state.items.concat(action.todo),
            saving: false
          }

        case TODOS_FAILURE:
          return {...state, loading: false, saving: false, error: action.error}

        case TOGGLE_TODO:
          return {
            ...state,
            items: state.items.map((todo) =>
              todo._id === action.id ? {...todo, done: !todo.done} : todo
            )
          }

        case DELETE_TODO:
          return {
            ...state,
            items: state.items.reduce((items, todo) =>
              todo._id !== action.id ? items.concat(todo) : items, []
            )
          }

        default:
          return state
      }
    }

Step 7: Handling Async with Redux Sagas

Install required packages in frontend: npm install redux-saga -- save

Setup Redux Sagas in React

    /* frontend/src/index.js */
    // ...
    import { Provider } from 'react-redux'
    import { createStore, applyMiddleware, compose } from 'redux'
    import createSagaMiddleware from 'redux-saga'
    import rootReducer, { DEFAULT_STATE } from './reducers'
    import rootSaga from './sagas'

    const sagaMiddleware = createSagaMiddleware()
    const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

    const store = createStore(
      rootReducer,
      DEFAULT_STATE,
      composeEnhancers(applyMiddleware(sagaMiddleware))
    );

    sagaMiddleware.run(rootSaga)
    //...

Implement Root Saga

    import { call, put, takeLatest, takeEvery } from 'redux-saga/effects'
    import { ADD_TODO, DELETE_TODO, TOGGLE_TODO, loadedTodos, addTodoSuccess, todosFailure } from '../actions/todos'

    function* getAllTodos () {
      try {
        const res = yield call(fetch, 'v1/todos')
        const todos = yield res.json()
        yield put(loadedTodos(todos))
      } catch (e) {
        yield put(todosFailure(e.message))
      }
    }

    function* saveTodo (action) {
      try {
        const options = {
          method: 'POST',
          body: JSON.stringify(action.todo),
          headers: new Headers({
            'Content-Type': 'application/json'
          })
        }

        const res = yield call(fetch, 'v1/todos', options)
        const todo = yield res.json()
        yield put(addTodoSuccess(todo))
      } catch (e) {
        yield put(todosFailure(e.message))
      }
    }

    function* deleteTodo (action) {
      try {
        yield call(fetch, `v1/todos/${action.id}`, { method: 'DELETE' })
      } catch (e) {
        yield put(todosFailure(e.message))
      }
    }

    function* updateTodo (action) {
      try {
        yield call(fetch, `v1/todos/${action.id}`, { method: 'POST' })
      } catch (e) {
        yield put(todosFailure(e.message))
      }
    }

    function* rootSaga() {
      yield takeLatest(FETCH_TODOS, getAllTodos)
      yield takeLatest(ADD_TODO, saveTodo)
      yield takeLatest(DELETE_TODO, deleteTodo)
      yield takeEvery(TOGGLE_TODO, updateTodo)
    }

    export default rootSaga

Step 8: Flow our new Redux actions through our UI

    /* frontend/src/Todos.js */
    import React, { Component } from 'react'
    import 'bulma/css/bulma.css'
    import { connect } from 'react-redux'
    import { addTodo, toggleTodo, deleteTodo, fetchTodos } from './actions/todos';

    const Todo = ({ todo, id, onDelete, onToggle }) => (
      <div className="box todo-item level is-mobile">
        <div className="level-left">
          <label className={`level-item todo-description ${todo.done && 'completed'}`}>
            <input className="checkbox" 
                   type="checkbox" 
                   checked={todo.done} 
                   onChange={onToggle}/>
            <span>{todo.description}</span>
          </label>
        </div>
        <div className="level-right">
          <a className="delete level-item" onClick={onDelete}>Delete</a>
        </div>
      </div>
    )

    class Todos extends Component {
      state = { newTodo: '' }

      componentDidMount() {
        this.props.fetchTodos()
      }

      addTodo (event) {
        event.preventDefault() // Prevent form from reloading page
        const { newTodo } = this.state

        if(newTodo) {
          const todo = { description: newTodo, done: false }
          this.props.addTodo(todo)
          this.setState({ newTodo: '' })
        }
      }

      render() {
        let { newTodo } = this.state
        const { todos, isLoading, isSaving, error, deleteTodo, toggleTodo } = this.props

        const total = todos.length
        const complete = todos.filter((todo) => todo.done).length
        const incomplete = todos.filter((todo) => !todo.done).length

        return (
          <section className="section full-column">
            <h1 className="title white">Todos</h1>
            <div className="error">{error}</div>

            <form className="form" onSubmit={this.addTodo.bind(this)}>
              <div className="field has-addons" style={{ justifyContent: 'center' }}>
                <div className="control">
                  <input className="input"
                         value={newTodo}
                         placeholder="New todo"
                         onChange={(e) => this.setState({ newTodo: e.target.value })}/>
                </div>

                <div className="control">
                  <button className={`button is-success ${(isLoading || isSaving) && "is-loading"}`}
                          disabled={isLoading || isSaving}>Add</button>
                </div>
              </div>
            </form>

            <div className="container todo-list">
              {todos.map((todo) => <Todo key={todo._id}
                                         id={todo._id}
                                         todo={todo}
                                         onDelete={() => deleteTodo(todo._id)}
                                         onToggle={() => toggleTodo(todo._id)}/> )}
              <div className="white">
                Total: {total}  , Complete: {complete} , Incomplete: {incomplete}
              </div>
            </div>
          </section>
        );
      }
    }

    const mapStateToProps = (state) => {
      return {
        todos: state.todos.items,
        isLoading: state.todos.loading,
        isSaving: state.todos.saving,
        error: state.todos.error
      }
    }

    const mapDispatchToProps = {
      addTodo,
      toggleTodo,
      deleteTodo,
      fetchTodos
    }

    export default connect(mapStateToProps, mapDispatchToProps)(Todos)

Now we have:

  • A very functional UI rendered through React components
  • The app state is managed through Redux
  • and Async is handled through Redux Sagas, beautiful!

Step 9: Deploying our application into the Real World

We are going to be using Zeit’s Now service to get our app deployed on the internet.

  1. Sign up for an account at https://zeit.co/now, they have a great free tier.
  2. Download their client at https://zeit.co/download
  3. Once setup, go to our main project directory and run our build command: npm run build
  4. Add whitelisted files to backend/.npmignore.json: (now ignores files in .gitignore)
    !build
  5. Go into our backend directory and run: now
    • You will need to login with your account
  6. Your application will start getting packaged up and deployed to Now
  7. Once successful, it will hand back a url where your app is now hosted
    e.g https://koalerplate-flwzaruvle.now.sh
  8. Woohoo!! We’re online! 🎉

Step 10: Celebrate!

Well done! If you’ve made it this far you’ve conquered a lot of mountains and have successfully built a full stack application. This paves the way for any project to come no matter how complex.

Now it’s time to sit back and tick off some sweet todo items… 😛

Rhys Davies
jtribe

rhys@jtribe.com.au
github.com/rhysdavies1994