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.

/* 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.

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.

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.

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

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.

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

// 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

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

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

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.

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

the dev script in the template starts the Nitric Server using nitric start, then runs your functions.

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

At this point, you can deploy what you've built to any of the supported cloud providers. In this example we'll deploy to AWS. Start by setting up your credentials and configuration for the nitric/aws provider.

Next, we'll need to create a stack file (deployment target). A stack is a deployed instance of an application. You might want separate stacks for each environment, such as stacks for dev, test, and prod. For now, let's start by creating a file for the dev stack.

The stack new command below will create a stack named dev that uses the aws provider.

nitric stack new dev aws

Edit the stack file nitric.dev.yaml and set your preferred AWS region, for example us-east-1.

# The nitric provider to use
provider: nitric/aws@latest
# The target AWS region to deploy to
# See available regions:
# https://docs.aws.amazon.com/general/latest/gr/lambda-service.html
region: us-east-2

You are responsible for staying within the limits of the free tier or any costs associated with deployment.

Let's try deploying the stack with the up command:

nitric up

When the deployment is complete, go to the relevant cloud console and you'll be able to see and interact with your application.

To tear down your application from the cloud, use 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

The Netlify.toml file in this repository includes the configuration for you to customize the API_BASE_URL property on the initial deploy.

Deploy to Netlify

Last updated on Oct 11, 2024