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-localstackplugin, 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.

