Serverless applications are transforming the way developers build and deploy APIs. With platforms like AWS, running backend services without managing infrastructure is now possible, allowing developers to focus solely on code. This article will walk you through building and deploying a Serverless CRUD (Create, Read, Update, Delete) API using Node.js, Express, DynamoDB, and AWS SAM (Serverless Application Model).

Why Serverless ?

Traditional API deployments often require managing infrastructure such as virtual machines or containers, configuring scaling policies, and monitoring system health. Serverless computing simplifies this by abstracting infrastructure management. In a serverless architecture, the cloud provider (AWS in this case) automatically scales and provisions the resources based on incoming requests.

The benefits of going serverless include:

  • Cost efficiency: You only pay for what you use (requests, execution time, etc.).
  • Scalability: The system auto-scales based on demand.
  • Operational simplicity: No need to manage servers or operating systems.
  • Resilience: High availability is built into the serverless architecture.

Pre-requisites

An AWS Account

Node.js Installed in system

AWS CLI Installed and Configured in the System (For more information you can check Configure the AWS CLI )

SAM CLI Installed in the System (For more information you can check Install the AWS SAM CLI )

Tools and Technologies

Before diving into the code, let’s briefly look at the key components we’ll be using:

  • Node.js: A JavaScript runtime used to build the server-side API.
  • Express.js: A web framework for Node.js to build APIs quickly and efficiently.
  • DynamoDB: A fully managed NoSQL database service provided by AWS.
  • AWS SAM (Serverless Application Model): A framework for building serverless applications on AWS. It simplifies Lambda function creation, API Gateway setup, and infrastructure management.
  • AWS Lambda: A compute service that runs the backend logic in a serverless manner.

Step 1 Setting up the development environment

Create a project folder in a convenient location in your machine and name it something like crud-rest-api-serverless . You can name your folder as per your wish. Open the folder with your editor of choice and then run the following command from command line to create package.json file

npm init -y

Create another file template file template.yaml. We will use this file later.

Step 2 DynamoDB table creation

Sign in to your AWS Console and then go to the DynamoDB section and create a table for our use. For this article, I am going to create a table example-todo-table as shown in the below image

Change no other settings and hit the Create Table button and it will create the table for you

Step 3 Installing Dependencies for the project

Let’s install dependencies for the project .

npm install express cors @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb serverless-http

Step 4 Create Express App

Create a folder src and create a file app.js under it and then write the following code

const express = require("express");
const cors = require("cors");
const app = express();
app.use(cors());
app.use(express.json());

module.exports = app;

Here in the above code we have written a basic express app and used cors package to allow cross-origin requests from front-end or mobile apps.

Step 5 Initialize DynamoDB and Create Service for CRUD Operations

Create two folders utils and services under the src folder

In the utils folder create a file dynamo.js and write the following code inside it

const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const LibDynamoDb = require("@aws-sdk/lib-dynamodb");

const client = new DynamoDBClient();
const docClient = LibDynamoDb.DynamoDBDocumentClient.from(client);

module.exports = docClient;

Here we are initializing DynamoDB using @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamdb these packages are part of JavaScript AWS SDK v3.

Now create a file todos.service.js under services folder and write the following code inside it

const {
  QueryCommand,
  GetCommand,
  PutCommand,
  UpdateCommand,
  ScanCommand,
  DeleteCommand,
} = require("@aws-sdk/lib-dynamodb");

const dynamoClient = require("../utils/dynamo");

async function createTodos(todos) {
  try {
    const scommand = new ScanCommand({
      TableName: process.env.TABLE_NAME,
      Select: "COUNT", // <- Wow! section for me
      ReturnConsumedCapacity: "INDEXES",
    });
    const response = await dynamoClient.send(scommand);
    if (response?.Count > 0) {
      todos.id = "TODO#" + Number(response?.Count + 1);
    } else {
      todos.id = "TODO#1";
    }

    const pcommand = new PutCommand({
      TableName: process.env.TABLE_NAME,
      Item: {
        PK: todos.id,
        todoTitle: todos.title,
        todoDescription: todos.description,
        todoDate: todos.date,
      },
    });

    const result = await dynamoClient.send(pcommand);

    return result;
  } catch (err) {
    throw new Error(err.message);
  }
}

async function listTodos() {
  try {
    const scommand = new ScanCommand({
      TableName: process.env.TABLE_NAME,
    });

    const result = await dynamoClient.send(scommand);
    return result;
  } catch (err) {
    throw new Error(err.message);
  }
}

async function getTodoById(id) {
  try {
    const gcommand = new GetCommand({
      TableName: process.env.TABLE_NAME,
      Key: {
        PK: id,
      },
    });
    const result = await dynamoClient.send(gcommand);
    return result;
  } catch (err) {
    throw new Error(err.message);
  }
}

async function updateTodo(todo) {
  try {
    const ucommand = new UpdateCommand({
      TableName: process.env.TABLE_NAME,
      UpdateExpression:
        "SET todoTitle=:todoTitle, todoDescription=:todoDescription, todoDate=:todoDate",
      Key: {
        PK: todo.id,
      },
      ExpressionAttributeValues: {
        ":todoTitle": todo.title,
        ":todoDescription": todo.description,
        ":todoDate": todo.date,
      },
    });
    const result = await dynamoClient.send(ucommand);
    return result;
  } catch (err) {
    throw new Error(err.message);
  }
}

async function deleteTodo(id) {
  try {
    const dcommand = new DeleteCommand({
      TableName: process.env.TABLE_NAME,
      Key: {
        PK: id,
      },
    });
    const result = await dynamoClient.send(dcommand);
    return result;
  } catch (err) {
    throw new Error(err.message);
  }
}

module.exports = {
  createTodos,
  listTodos,
  getTodoById,
  updateTodo,
  deleteTodo,
};

Here in the above code, we have created 5 functions createTodos, listTodos, getTodoById, updateTodo and
deleteTodo
. The names of the functions are self-explanatory. These are all very basic functions and only for demonstration purposes and no validation has been imposed on these functions. You can provide your business logic inside the functions to accomplish your desired result.

Step 6 Create Controller

Let’s create a folder controllers under src directory and create a file todo.cotroller.js and add the following code inside it

const {
  createTodos,
  listTodos,
  getTodoById,
  updateTodo,
  deleteTodo,
} = require("../services/todo.service");
async function create(req, res) {
  try {
    const result = await createTodos(req.body);

    return res.status(201).json({
      message: "Response from Create API",
      data: result,
    });
  } catch (err) {
    return res.status(500).json({
      message: "Something went wrong",
      error: err.message,
    });
  }
}

async function list(req, res) {
  try {
    const result = await listTodos();
    return res.status(200).json({
      message: "Response from list API",
      data: result,
    });
  } catch (err) {
    return res.status(500).json({
      message: "Something went wrong",
      error: err.message,
    });
  }
}

async function getbyid(req, res) {
  try {
    const result = await getTodoById(req.params.id);
    return res.status(200).json({
      message: "Response from get by id API with id:" + req.params.id,
      data: result,
    });
  } catch (err) {
    return res.status(500).json({
      message: "Something went wrong",
      error: err.message,
    });
  }
}

async function update(req, res) {
  try {
    const result = await updateTodo(req.body);
    return res.status(200).json({
      message: "Response from update API",
      data: result,
    });
  } catch (err) {
    return res.status(500).json({
      message: "Something went wrong",
      error: err.message,
    });
  }
}

async function remove(req, res) {
  try {
    const result = await deleteTodo(req.params.id);

    return res.status(200).json({
      message: "Response from remove API" + req.params.id,
    });
  } catch (err) {
    return res.status(500).json({
      message: "Something went wrong",
      error: err.message,
    });
  }
}

module.exports = {
  create,
  list,
  getbyid,
  update,
  remove,
};

In the above code we have create 5 functions create, list, getbyid, update, remove in order to accept the request data call the corresponding services to store, retrieve modify, and delete results from the DynamoDB database. Again these functions are basic and only for demonstration purposes.

Step 7 Create Routes

Create a folder routes under src folder and create a file todo.routes.js under routes folder . Now add the following code inside it

const express = require("express");
const {
  create,
  list,
  getbyid,
  update,
  remove,
} = require("../controllers/todo.controller");
const router = express.Router();

router.post("/create", create);
router.get("/list", list);
router.get("/:id", getbyid);
router.put("/update", update);
router.delete("/delete/:id", remove);

module.exports = router;

Now add the routes to our app.js and our final code for app.js will look like the below

const express = require("express");
const todoRoutes = require("./routes/todo.routes");
const cors = require("cors");
const app = express();
app.use(cors());
app.use(express.json());
app.use("/api/todos", todoRoutes);

module.exports = app;

Step 8 Creating Handler

In AWS Lambda, a handler function is the method in a function’s code that processes events. When a Lambda function is invoked, the handler method is run by Lambda. The function will continue to run until the handler returns a response, times out, or exits. In our app we will use serverless-http package which has been installed earlier in order to create the handler.

So without any further delay lets create a folder handler under src and create file index.js under it. Then add the following code

const Serverless = require("serverless-http");
const app = require("../app");

exports.handler = Serverless(app);

With this we have completed our express app development and the final folder structure will look like the following

Step 9 Lets code our template.yaml

The AWS SAM template.yaml file contains the code that defines a serverless application’s resources, event source mappings, and other properties. The typical code for our template.yaml is given as below

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Parameters:
  TableName:
    Type: String

Globals:
  Api:
    Cors:
      AllowMethods: "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'"
      AllowHeaders: "'Content-Type,Authorization'"
      AllowOrigin: "'*'"
      AllowCredentials: "'*'"

Resources:
  CRUDApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/handler/index.handler
      Environment:
        Variables:
          TABLE_NAME: !Ref TableName
      Runtime: nodejs20.x
      CodeUri: .
      MemorySize: 128
      Timeout: 100
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref TableName

      Events:
        CreateAPI:
          Type: Api
          Properties:
            Path: /api/todos/create
            Method: POST

        ListAPI:
          Type: Api
          Properties:
            Path: /api/todos/list
            Method: GET
        GetByIDApi:
          Type: Api
          Properties:
            Path: /api/todos/{id}
            Method: GET
        UpdateAPI:
          Type: Api
          Properties:
            Path: /api/todos/update
            Method: PUT
        RemoveAPI:
          Type: Api
          Properties:
            Path: /api/todos/delete/{id}
            Method: DELETE
Outputs:
  WebEndpoint:
    Description: "API Gateway endpoint URL for Prod stage"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"

An AWS SAM template file closely follows the format of an AWS CloudFormation template file. Some primary differences between the AWS SAM template and the CloudFormation template are as follows

  • Transform declaration. The declaration Transform: AWS::Serverless-2016-10-31 is required for AWS SAM template files. This declaration identifies an AWS CloudFormation template file as an AWS SAM template file. For more information about transforms, see Transform in the AWS CloudFormation User Guide.
  • Globals section. The Globals section is unique to AWS SAM. It defines properties that are common to all your serverless functions and APIs. All the AWS::Serverless::FunctionAWS::Serverless::Api, and AWS::Serverless::SimpleTable resources inherit the properties that are defined in the Globals section. For more information about this section, see Globals section of the AWS SAM template.
  • Resources section. In AWS SAM templates the Resources section can contain a combination of AWS CloudFormation resources and AWS SAM resources. For more information about AWS CloudFormation resources, see AWS resource and property types reference in the AWS CloudFormation User Guide. For more information about AWS SAM resources, see AWS SAM resources and properties.

A few things in the above template file are very much needed in order for the APIs to work properly .

  • Parameters : It defines the name of the table to be passed as the reference to be used in the Policy . When running the APIs locally we will pass the TableName parameter from Command Line and When deploying we will provide the TableName when prompted during guided deployment. This
  • Under the Globals Section, The Cors section is necessary if you are going to use these APIs for frontend or mobile app development. It will allow cross-origin requests to API Gateway
  • Under Resource section All properties are very much necessary in order for the APIs to work.

Step 10 Running The APIs locally

First, you need to validate the template to see if the alignment or any particular property is not valid . In order to do that from your command line issue the following command

sam validate

It will throw any error if the template.yaml is not valid. If it passes the validation the next step is the build the app and in order to do that we will issue the following command

sam build

If the build is successful you will see something like this in your console

It will create a folder .aws-sam in your project folder

Next step is to run the API locally in order to test our application before deployment. So we need to use the command ‘sam local start-api‘ which will mimic the AWS API Gateway and allow us to test our endpoints. The full command is shown below

sam local start-api --parameter-overrides TableName=example-todo-table --profile account1

Few things to notice here –parameter-overrides TableName=example-todo-table provides the value for TableName parameter which has been declared in the Parameters section of the template.yaml and –profile account1 provides the name of the AWS Profile to be used so that different AWS Resources can be accessed by using the region and credential details of the profile. So it is very important and it has been mentioned at the top of the article that AWS CLI is necessary to be configured. If you have multiple profiles you can check for a file .aws/credentials in order to see the profile name and use it in the command.

Lastly to deploy the application use the following command

sam build --guided --profile account1

In issuance of the above command you will be asked a series of questions to gather necessary information for deployment by the SAM CLI and it may look like the following screenshots

The Operation may vary in your case.

In the Output section you can see the Base URL for the deployed application. You can use this endpoint to test it from POSTMAN or from your frontend app etc.

Some Gotchas

  • Sometimes you may see an error like Error: Failed to create changeset for the stack: lambda-function-name, An error occurred (ValidationError) when calling the CreateChangeSet operation:Template format error: Unrecognized resource types: [AWS::Serverless::Function] during sam build or sam deploy. In order to get rid of this you can delete .aws-sam folder completely and then start the build. It should go through this time.
  • For CORS Errors you must look in the Cors under the Globals for anything missing the AllowOrgins or AllowMethods or AllowHeaders.

Hope this article will help to progress in your Serverless journey in AWS . All the best and Happy Coding!

Previous post Everything you need to know about building a REST API to Upload files in AWS S3 with Node.js and Typescript