React.js

Building a Full-Stack Serverless CRUD App with AWS and React

In recent years, serverless computing has revolutionized how developers build and deploy applications in the cloud. By abstracting away server management, serverless enables you to focus purely on writing business logic, while cloud providers handle scaling, availability, and infrastructure maintenance automatically. However, developing and testing serverless applications directly on the cloud can be time-consuming, costly, and slow down iteration cycles. Let us delve into understanding how to build a full-stack serverless app that is scalable, cost-effective, and easy to develop.

1. Understanding Serverless Architecture

Serverless is a cloud-native execution model where you build and deploy applications without managing or provisioning servers. Instead of maintaining EC2 instances, containers, patching OS versions, or scaling clusters, you simply write small, event-driven functions and the cloud provider (AWS, Azure, GCP) handles:

  • Automatic scaling
  • High availability
  • Infrastructure maintenance
  • Resource allocation based on demand

In serverless, pricing follows a pay-per-use model. You only pay for function execution time or consumed storage, making it very cost-effective for workloads with variable or unpredictable traffic. On AWS, the commonly used Serverless components include:

  • AWS Lambda – Executes your functions without provisioning servers.
  • API Gateway – Creates REST/HTTP endpoints for clients to access your Lambdas.
  • DynamoDB – Fully managed NoSQL database ideal for serverless apps.
  • S3 – Object storage for static files, frontend hosting, images, etc.
  • Step Functions – Orchestrates workflows across multiple Lambdas and services.

Together, these services allow you to build a fully scalable backend without touching a single server.

1.1 Local Development with LocalStack

LocalStack is a powerful tool that emulates AWS cloud services locally on your machine. It provides offline versions of key AWS services such as Lambda, API Gateway, DynamoDB, S3, SQS, SNS, and many more—allowing you to develop and test serverless applications without deploying to AWS. Key Reasons to Use LocalStack:

  • Faster development cycle: No need to deploy to AWS every time you change code. Serverless functions and infrastructure provision instantly on your laptop.
  • Cost-efficient: Zero AWS charges while developing. This is especially helpful for beginners or teams building large test environments.
  • Improved debugging: Run Lambdas locally, inspect API Gateway logs, verify IAM access, and test DynamoDB queries—everything without the cloud.
  • CI/CD compatible: LocalStack works well inside pipelines, allowing teams to run integration tests without AWS dependency.
  • Serverless Framework support: Using the serverless-localstack plugin, Serverless Framework deployments seamlessly redirect to LocalStack services.
  • Offline-first development: Perfect for environments with restricted internet or for developers traveling/off-grid.

LocalStack effectively simulates the AWS environment so precisely that many workflows behave the same as real AWS—including CloudFormation deployments, event triggers, DynamoDB operations, and Lambda execution.

2. Code Example

2.1 Backend Development: AWS Lambda Functions with Node.js

We’ll implement a single Lambda with multiple HTTP routes (Serverless Framework + API Gateway handles mapping) or separate functions. For clarity we’ll implement separate handlers in /backend/handlers and a shared DynamoDB helper.

2.1.1 Setting Up package.json for Lambda Functions

The package.json file is the heart of any Node.js project. It defines the project’s metadata, manages dependencies, and sets up useful scripts to streamline development and deployment workflows. For our serverless backend, this file will specify the necessary packages and configurations to run Lambda functions smoothly both locally and in the cloud.

{"name":"notes-backend","version":"1.0.0","main":"handlers.js","scripts":{"start":"node -v"},"dependencies":{"aws-sdk":"^2.1400.0","uuid":"^9.0.0"}}

By running npm install after creating this file, you’ll download and install all required packages locally, enabling your Lambda functions to interact seamlessly with AWS or LocalStack services. This file forms the foundation for dependency management and ensures consistency across different development and deployment environments.

2.1.2 Implementing Lambda Handlers for CRUD Operations

This file contains all backend Lambda functions—create, list, update, and delete notes—along with the DynamoDB setup. It uses the AWS SDK, connects to LocalStack when an endpoint is provided, and exposes each CRUD operation as a separate exported function.

// handlers.js
const AWS = require('aws-sdk');
const { v4: uuidv4 } = require('uuid');

// DynamoDB Setup
const options = {};
if (process.env.AWS_ENDPOINT) {
  options.endpoint = process.env.AWS_ENDPOINT; // localstack endpoint
}

const client = new AWS.DynamoDB.DocumentClient(options);
const TABLE = process.env.NOTES_TABLE || 'Notes';

// CREATE NOTE
module.exports.create = async (event) => {
  const body = JSON.parse(event.body || '{}');

  if (!body.text) {
    return {
      statusCode: 400,
      body: JSON.stringify({ message: 'text is required' })
    };
  }

  const item = {
    id: uuidv4(),
    text: body.text,
    createdAt: new Date().toISOString()
  };

  await client.put({ TableName: TABLE, Item: item }).promise();

  return {
    statusCode: 201,
    body: JSON.stringify(item)
  };
};

// LIST NOTES
module.exports.list = async () => {
  const res = await client.scan({ TableName: TABLE }).promise();
  return {
    statusCode: 200,
    body: JSON.stringify(res.Items || [])
  };
};

// UPDATE NOTE
module.exports.update = async (event) => {
  const id = event.pathParameters && event.pathParameters.id;
  const body = JSON.parse(event.body || '{}');

  if (!id || !body.text) {
    return {
      statusCode: 400,
      body: JSON.stringify({ message: 'id and text required' })
    };
  }

  const params = {
    TableName: TABLE,
    Key: { id },
    UpdateExpression: 'SET #t = :text',
    ExpressionAttributeNames: { '#t': 'text' },
    ExpressionAttributeValues: { ':text': body.text },
    ReturnValues: 'ALL_NEW'
  };

  const res = await client.update(params).promise();
  return {
    statusCode: 200,
    body: JSON.stringify(res.Attributes)
  };
};

// DELETE NOTE
module.exports.delete = async (event) => {
  const id = event.pathParameters && event.pathParameters.id;

  if (!id) {
    return {
      statusCode: 400,
      body: JSON.stringify({ message: 'id required' })
    };
  }

  await client.delete({ TableName: TABLE, Key: { id } }).promise();

  return {
    statusCode: 204,
    body: ''
  };
};

This code defines a set of AWS Lambda functions to perform CRUD (Create, Read, Update, Delete) operations on a DynamoDB table named Notes. It begins by importing the AWS SDK to interact with AWS services and the uuid library to generate unique identifiers. The options object configures the DynamoDB client to connect to a local endpoint if the AWS_ENDPOINT environment variable is set (used for LocalStack emulation). A DocumentClient instance is created to perform DynamoDB operations in a more developer-friendly way. The table name is retrieved from the NOTES_TABLE environment variable or defaults to ‘Notes’. The create function parses the incoming HTTP event body, validates that the required text field exists, and constructs a new note item with a unique ID and timestamp before saving it to DynamoDB; it returns the created item with a 201 status code. The list function scans the entire DynamoDB table and returns all notes with a 200 status code. The update function extracts the note ID from the URL path and new text from the body, validates both, then updates the note’s text in DynamoDB using an update expression, returning the updated item. The delete function deletes a note by its ID after validation and returns a 204 status code indicating successful deletion with no content. Each function handles input validation and responds with appropriate HTTP status codes and JSON-formatted bodies to enable smooth integration with API Gateway and frontend clients.

2.1.3 Configuring Serverless Framework with serverless.yml

This configuration file tells the Serverless Framework how to deploy your backend. It defines AWS as the provider, registers all Lambda functions, enables LocalStack and offline emulation, configures environment variables, and provisions the DynamoDB table required by the notes application.

# backend/serverless.yml
service: notes-service

provider:
  name: aws
  runtime: nodejs18.x
  region: us-east-1
  environment:
    NOTES_TABLE: Notes
    AWS_ENDPOINT: http://localhost:4566   # LocalStack endpoint, used inside handlers.js

plugins:
  - serverless-offline
  - serverless-localstack

custom:
  localstack:
    stages: [ local ]
    endpointFile: ./localstack_endpoints.json

functions:
  create:
    handler: handlers.create
    events:
      - http:
          path: notes
          method: post

  list:
    handler: handlers.list
    events:
      - http:
          path: notes
          method: get

  update:
    handler: handlers.update
    events:
      - http:
          path: notes/{id}
          method: put

  delete:
    handler: handlers.delete
    events:
      - http:
          path: notes/{id}
          method: delete

resources:
  Resources:
    NotesTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: Notes
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST

This serverless.yml configuration file defines the Serverless Framework service named notes-service which is designed to run on AWS with Node.js 18.x runtime in the us-east-1 region. It sets environment variables such as NOTES_TABLE for the DynamoDB table name and AWS_ENDPOINT pointing to LocalStack’s local endpoint (http://localhost:4566), enabling local AWS service emulation. The file includes plugins serverless-offline for local API Gateway simulation and serverless-localstack to route AWS SDK calls to LocalStack during local development. Under custom, LocalStack is configured to run in the local stage using endpoints defined in an external JSON file. The functions section declares four Lambda functions (create, list, update, and delete) mapped to HTTP API Gateway events with respective RESTful paths and methods for managing notes. Finally, the resources section defines the DynamoDB table resource named Notes with a string primary key id and on-demand billing mode (PAY_PER_REQUEST), allowing automatic scaling based on usage. This configuration enables seamless deployment and local testing of the full CRUD backend within a consistent infrastructure-as-code framework.

2.1.4 Deploying and Testing the Backend Locally with LocalStack

Once your backend code and serverless.yml configuration are ready, you can deploy the entire Serverless service locally using LocalStack. This lets you emulate AWS Lambda, API Gateway, and DynamoDB on your machine with no AWS charges and no internet dependency. The deployment flow mirrors a real AWS deployment, making local testing extremely accurate and production-ready.

2.1.4.1 Prerequisites for Local Deployment

Before deploying, ensure the following tools are installed and configured:

  • Node.js (v18+ recommended)
  • Serverless Framework installed globally:
    npm install -g serverless
  • LocalStack installed — it simulates AWS services (Lambda, DynamoDB, S3, API Gateway, etc.) entirely on your local machine. Install via pip:
    pip install localstack
  • serverless-localstack plugin installed (required so that Serverless routes AWS calls to LocalStack):
    npm install --save-dev serverless-localstack
  • AWS CLI configured — LocalStack accepts any credentials, but they must be set.
    aws configure
    AWS Access Key ID: test
    AWS Secret Access Key: test
    Region: us-east-1
    

These prerequisites ensure that Serverless Framework can deploy functions, create DynamoDB tables, and register API Gateway routes exactly as it would on AWS, but entirely inside LocalStack.

2.1.4.2 Starting LocalStack

Run LocalStack in a separate terminal window so it can emulate AWS services while Serverless deploys your stack.

localstack start

When LocalStack is running, all AWS service endpoints (Lambda, DynamoDB, API Gateway) become available on http://localhost:4566. Your backend’s handlers.js automatically connects to this endpoint because the AWS_ENDPOINT environment variable is passed from serverless.yml.

2.1.4.3 Deploying the Backend Service

With LocalStack running, you can now deploy your backend just like you would deploy to AWS. The serverless-localstack plugin ensures Serverless Framework redirects CloudFormation, Lambda, and API Gateway calls to LocalStack internally. This means sls deploy behaves identically to a real cloud deployment but executes entirely on your machine.

cd backend
sls deploy --stage local

After deployment completes, Serverless prints the automatically generated API Gateway endpoints. LocalStack simulates API Gateway with a special URL format consisting of the REST API ID, stage name, and a _user_request_ suffix used to route requests correctly:

POST   http://localhost:4566/restapis/<api-id>/local/_user_request_/notes
GET    http://localhost:4566/restapis/<api-id>/local/_user_request_/notes
PUT    http://localhost:4566/restapis/<api-id>/local/_user_request_/notes/{id}
DELETE http://localhost:4566/restapis/<api-id>/local/_user_request_/notes/{id}

At this point your backend is fully deployed inside LocalStack. You can test it using curl, Postman, or by hooking it directly into the frontend through a configured REACT_APP_API_BASE. This setup allows rapid development with zero AWS costs and complete API parity with the real cloud environment.

2.2 Frontend Development: React Application

Frontend is a minimal React app that calls the API. Use create-react-app or Vite.

2.2.1 Creating the Main React Component (App.js)

This is the main React component for the frontend. It communicates with the backend using the base URL defined in the REACT_APP_API_BASE environment variable. If the variable is not set, it defaults to http://localhost:3000, which is where serverless-offline typically exposes the API. The component handles fetching notes, creating new notes, updating existing notes, and deleting notes—all through direct REST calls to the backend endpoints. It also manages UI state for the input field and edit mode, updating the list of notes dynamically as operations succeed.

import React, { useEffect, useState } from 'react';

// Default to serverless-offline running on http://localhost:3000
// You can override using REACT_APP_API_BASE
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3000';

function App() {
  const [notes, setNotes] = useState([]);
  const [text, setText] = useState('');
  const [editing, setEditing] = useState(null);

  // -----------------------------
  // Fetch Notes
  // -----------------------------
  useEffect(() => {
    fetch(`${API_BASE}/notes`)
      .then(r => r.json())
      .then(setNotes)
      .catch(console.error);
  }, []);

  // -----------------------------
  // Create
  // -----------------------------
  async function createNote(e) {
    e.preventDefault();
    const res = await fetch(`${API_BASE}/notes`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text })
    });

    const data = await res.json();
    setNotes(prev => [data, ...prev]);
    setText('');
  }

  // -----------------------------
  // Update
  // -----------------------------
  async function updateNote(id) {
    const res = await fetch(`${API_BASE}/notes/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text })
    });

    const updated = await res.json();
    setNotes(prev => prev.map(n => (n.id === id ? updated : n)));
    setEditing(null);
    setText('');
  }

  // -----------------------------
  // Delete
  // -----------------------------
  async function deleteNote(id) {
    await fetch(`${API_BASE}/notes/${id}`, { method: 'DELETE' });
    setNotes(prev => prev.filter(n => n.id !== id));
  }

  return (
    <div style={{ padding: 20 }}>
      <h2>Notes</h2>

      <form onSubmit={editing ? () => updateNote(editing) : createNote}>
        <input
          value={text}
          onChange={e => setText(e.target.value)}
          placeholder="Write a note"
          required
        />
        <button type="submit">{editing ? 'Update' : 'Create'}</button>

        {editing && (
          <button
            type="button"
            onClick={() => {
              setEditing(null);
              setText('');
            }}
            style={{ marginLeft: 8 }}
          >
            Cancel
          </button>
        )}
      </form>

      <ul>
        {notes.map(n => (
          <li key={n.id} style={{ marginTop: 10 }}>
            <div>{n.text}</div>
            <div className="muted">id: {n.id}</div>
            <div style={{ marginTop: 6 }}>
              <button
                onClick={() => {
                  setEditing(n.id);
                  setText(n.text);
                }}
              >
                Edit
              </button>
              <button
                onClick={() => deleteNote(n.id)}
                style={{ marginLeft: 8 }}
              >
                Delete
              </button>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

2.2.2 Setting Up package.json and Environment Variables for Frontend

{
  "name": "notes-frontend",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  }
}

Next, create a file named .env.local in the frontend folder. This environment file sets the base URL for API requests, pointing them to the LocalStack-emulated backend:

REACT_APP_API_BASE=http://localhost:4566/restapis/<api-id>/local/_user_request_

This environment variable is automatically accessed by the frontend React component (App.js), ensuring that all fetch calls are correctly routed to the locally deployed Serverless backend via LocalStack. By setting this variable, you enable seamless communication between the frontend and backend during local development without needing to hardcode API endpoints.

2.2.3 Running the Frontend and Connecting to Backend APIs

Start the frontend by running npm start, which will launch the UI and enable you to interact with the serverless backend APIs to fetch and manage data seamlessly.

Sign up

Yatin Batra

An experience full-stack engineer well versed with Core Java, Spring/Springboot, MVC, Security, AOP, Frontend (Angular & React), and cloud technologies (such as AWS, GCP, Jenkins, Docker, K8).
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Back to top button