Next.js and Nitric example application

This example application demonstrates a To-Do List built using Next.js for the frontend and Nitric for the API.

Prerequisites

Before getting started you'll want Node.js and the Nitric CLI installed. If you'd like to deploy the application to the cloud you'll also need an AWS, Google Cloud or Azure account. The code for the application will be the same regardless of the cloud you choose. You can also use Vercel or Netlify to deploy the frontend if you choose.

Getting started

Start by cloning the Nitric to-do project from GitHub and installing dependencies. The rest of this guide will walk you through the example and how it's built.

git clone https://github.com/nitrictech/nitric-todo.git
cd nitric-todo
npm install

Now you can open the project in your editor of choice.

Project structure

The project is split into two components:

  • todo-api - This is the API built with Nitric
  • web - This is the Next.js frontend

Define types

Since we're using TypeScript we've defined types for tasks and the api request/response payloads.

todo-api/types.ts
/* Base Types */
export interface Task {
  id: string
  createdAt: number
  name: string
  complete: boolean
  description?: string
  dueDate?: number
}

export interface TaskList {
  id: string
  createdAt: number
  name: string
  tasks: Task[]
}

/* Task List */
export type Filters = Partial<Task>
export type TaskListResponse = TaskList
export type TaskListRequest = Omit<TaskList, 'id' | 'tasks'>
export type TaskListPostRequest = Omit<TaskList, 'id' | 'complete'>

/* Task Post */
export type TaskPostRequest = Omit<Task, 'id'>
/* Task Update */
export type TaskPatchRequest = { completed: boolean }

Add cloud resources

Apps built with Nitric define resources in code, you can write this in the root of any .js or .ts file, but for organization we recommend putting them together. So let's start by defining the resources we'll need to support our API in a resources directory.

todo-api/resources/apis.ts
import { api } from '@nitric/sdk'

export const taskListApi = api('taskList')

We also want a key/value store to store task lists.

import { kv } from '@nitric/sdk'
import { TaskList } from 'types'

export const taskLists = kv<TaskList>('taskLists')

Define API routes

Next, we setup API routes, these can remain as empty functions until we're ready to fill them in.

todo-api/functions/tasks.ts
import { taskListApi } from '../resources/apis.ts';

taskListApi.get("/:listid/:id", async (ctx) => {});      // Get task with [id]
taskListApi.get("/:listid", async (ctx) => {);           // Get task list with [id]
taskListApi.post("/:listid", async (ctx) => {});         // Post new task for task list
taskListApi.post("/", async (ctx) => {});                // Post new task list
taskListApi.patch("/:listid/:id", async (ctx) => {});    // Update task
taskListApi.delete("/:listid", async (ctx) => {});       // Delete task list
taskListApi.delete("/:listid/:id", async (ctx) => {});   // Delete task

Now we can import the task list key/value store and request permissions that allow this service to access it.

todo-api/functions/tasks.ts
import { taskListCol } from '../resources/stores.ts'

const taskLists = taskListCol.allow('set', 'get', 'delete')

With access to the key/value store, we can start adding tasks and task lists.

Create a task list

todo-api/functions/tasks.ts
taskListApi.post('/', async (ctx) => {
  const { name, tasks } = ctx.req.json() as TaskListPostRequest

  try {
    if (!name) {
      ctx.res.body = 'A new task list requires a name'
      ctx.res.status = 400
      return
    }

    const id = uuid.generate()
    const now = new Date().getTime()

    await taskLists.set(id, {
      id,
      name,
      createdAt: now,
      tasks: tasks.map(task => ({
        ...task,
        complete: false,
        createdAt: now,
      })
    })

    ctx.res.body = 'Successfully added task list!'
  } catch (err) {
    console.log(err)
    ctx.res.body = 'Failed to add task list'
    ctx.res.status = 400
  }

  return ctx
})

Create a new task

We can now accept task list ids and use them to add new tasks under that list.

todo-api/functions/tasks.ts
taskListApi.post('/:listid', async (ctx) => {
  const { listid } = ctx.req.params
  const task = ctx.req.json() as TaskPostRequest

  try {
    if (!listid) {
      ctx.res.body = 'A task list id is required'
      ctx.res.status = 400
      return
    }

    if (!task || !task.name) {
      ctx.res.body = 'A task with a name is required'
      ctx.res.status = 400
      return
    }

    const taskId = uuid.generate()

    const list = await taskLists.get(listid)

    list = {
      ...list,
      tasks: [
        ...list.tasks,
        {
          ...task,
          id: taskId,
          complete: false,
          createdAt: new Date().getTime(),
        },
      ],
    }

    await taskLists.set(listid, list)

    ctx.res.body = 'Successfully added task!'
  } catch (err) {
    console.log(err)
    ctx.res.body = 'Failed to add task list'
    ctx.res.status = 400
  }

  return ctx
})

Retrieve a task with filters

todo-api/functions/tasks.ts
// Get all tasks from a task list, with filters
taskListApi.get('/:listid', async (ctx) => {
  const { listid } = ctx.req.params
  const filters = ctx.req.query as Filters

  try {
    const { tasks } = await taskLists.get(listid)

    const filteredTasks = tasks.filter((task) => {
      return Object.entries(filters).every(([k, v]) => {
        switch (k) {
          case 'complete': {
            return task[k] === v
          }
          case 'dueDate': {
            return task[k] >= v
          }
          default: {
            return task[k].startsWith(v)
          }
        }
      })
    })

    ctx.res.json({
      ...taskList,
      tasks: filteredTasks,
    })
  } catch (err) {
    console.log(err)
    ctx.res.body = 'Failed to retrieve tasks'
    ctx.res.status = 400
  }

  return ctx
})

Retrieve a task from a task list

todo-api/functions/tasks.ts
taskListApi.get('/:listid/:id', async (ctx) => {
  const { listid, id } = ctx.req.params

  try {
    const list = await taskLists.get(listid)
    const task = list.tasks.find((task) => task.id === id)
    ctx.res.json(task)
  } catch (err) {
    console.log(err)
    ctx.res.body = 'Failed to retrieve tasks'
    ctx.res.status = 400
  }

  return ctx
})

Update a task

todo-api/functions/tasks.ts
taskListApi.patch('/:listid/:id', async (ctx) => {
  const { listid: listId, id } = ctx.req.params
  const { completed } = ctx.req.json() as ToggleRequest

  try {
    const list = await taskLists.get(listId)
    const task = list.tasks.find((task) => task.id === id)
    task.complete = completed
    await taskLists.set(listId, list)

    ctx.res.body = 'Successfully updated task'
  } catch (err) {
    console.log(err)
    ctx.res.body = 'Failed to retrieve tasks'
    ctx.res.status = 400
  }

  return ctx
})

Delete a task

todo-api/functions/tasks.ts
taskListApi.delete('/:listid/:id', async (ctx) => {
  const { listid: listId, id } = ctx.req.params

  try {
    const list = await taskLists.get(listId)
    list.tasks = list.tasks.filter((task) => task.id !== id)
    await taskLists.set(listId, list
  } catch (err) {
    console.log(err)
    ctx.res.body = 'Failed to delete task'
    ctx.res.status = 400
  }

  return ctx
})

Delete a task list

taskListApi.delete('/:id', async (ctx) => {
  const { id } = ctx.req.params

  try {
    await taskLists.delete(id)
    ctx.res.body = 'Successfully deleted task list'
  } catch (err) {
    console.log(err)
    ctx.res.body = 'Failed to delete task list'
    ctx.res.status = 400
  }

  return ctx
})

Set up API proxy

To avoid any CORS issues we can use the Next.js backend as a proxy for the Nitric API. This is a quick way to ensure the API can be called from the same origin.

Start by create your .env file by renaming the .env.example file:

mv web/.env.example web/.env

Within the next.config.js you should have rewrites defined to proxy between your universal Next.js API route and your Nitric APIs. It takes the API_BASE_URL variable which is defined in the .env file.

web/next.config.js
module.exports = {
  reactStrictMode: true,
  api: {
    bodyParser: {
      bodyParser: false, // Disallow body parsing, consume as stream
    },
  },
  // To avoid any CORs issues use Next.js as a proxy for Nitric API
  // We are working on it :)
  async rewrites() {
    return [
      {
        source: '/apis/:path*',
        destination: `${process.env.API_BASE_URL}/:path*`, // Proxy to Backend
      },
    ]
  },
}

Running the application

Now that you have an API defined with handlers for each of the methods, we can test it out locally.

You can test the application with the npm run dev command:

cd todo-api
npm run dev

We can then launch the Next.js frontend in a new terminal with:

cd ../web
npm run dev

Navigate to localhost:3000 to view the application. Alternatively, you can test the API directly at localhost:4001 using the Local Dashboard or any other HTTP client.

Pressing ctrl + a + k will end the application.

Deploy to the cloud

Deploy the Nitric API

Setup credentials and cloud specific configuration:

Create a stack, which is a deployment target for your application.

nitric stack new
? What should we name this stack? todo
? Which provider do you want to deploy with? aws
? Which region should the stack deploy to? us-east-1

Then, you can deploy with the up command.

nitric up

When the deployment is complete, go to the relevant cloud console to see and interact with the API.

To undeploy run the down command:

nitric down

Deploy the Next.js App

Choose one of the following deploy buttons and make sure to update the API_BASE_URL variable during the setup process with the deployed API URL.

Deploy with Vercel

Deploy to Netlify