DEV Community

Cover image for Building a Serverless Student Data App on AWS with S3, CloudFront, API Gateway, Lambda and DynamoDB
Bilal Bukhari
Bilal Bukhari

Posted on

Building a Serverless Student Data App on AWS with S3, CloudFront, API Gateway, Lambda and DynamoDB

I recently built a small but complete serverless application on AWS: a web page where you can save a student's record and view everyone who has been saved. There are no servers to manage, no containers, and almost nothing billing when nobody is using it.

In this post I'll walk through how I put it together, the code I used, the mistakes I made along the way, and what I would change before calling it production-ready.

Who this is for: anyone with an AWS account who wants a hands-on first serverless project. You should be comfortable with the AWS console, basic Python (for Lambda) and basic JavaScript (for the frontend). Expect it to take roughly 45 minutes.

What you'll learn:

  • How the main AWS serverless services fit together
  • How to give a Lambda function access to DynamoDB without putting credentials in code
  • How to build a REST API in API Gateway and avoid the CORS trap
  • How to host a static frontend on S3 and serve it through CloudFront

What I built

The app has two features:

  • Save Student Data: a form with Student ID, Name, Class and Age that stores a record.
  • View all Students: a button that fetches every record and shows it in a table.

It's deliberately simple. The point isn't the app itself, it's seeing how a request travels through each AWS service from the browser to the database and back.

The Save and View Student Data frontend after saving a student

The architecture

Behind that simple page is a chain of managed AWS services. The browser loads the page from CloudFront (backed by a private S3 bucket), then calls API Gateway directly:

Page load:  Browser -> CloudFront -> S3 (private bucket)

API calls:  Browser -> API Gateway (REST API)
                          |-> Lambda (POST) -> DynamoDB (write)
                          |-> Lambda (GET)  -> DynamoDB (read)
Enter fullscreen mode Exit fullscreen mode

Here is the same flow as a diagram:

Each service has one clear job:

Layer Service Job
Frontend S3 + CloudFront Hosts the HTML/JS page over HTTPS
API API Gateway (REST) Public HTTP endpoints
Compute Lambda Business logic for GET and POST
Database DynamoDB Stores student records
Security IAM Lets Lambda talk to DynamoDB

The key idea is that the frontend and the API are separate. S3 and CloudFront only deliver the static page. Once it loads in the browser, the JavaScript calls API Gateway on its own, which is why CORS matters so much later in this post.

Prerequisites

  • An AWS account
  • Basic familiarity with the AWS console
  • Some Python (for Lambda) and JavaScript (for the frontend)

I built everything in the us-east-1 (N. Virginia) region. Keep all services in the same region to avoid confusing "why can't I see my table" moments. (CloudFront is global, but the S3 bucket, API, Lambda functions and table should all live in one region.)

Step 1: Create the DynamoDB table

I started with the data layer because everything else depends on it. The Lambda functions need a table to talk to, and the API needs functions to call, so building from the database outward keeps each step testable.

  1. Open DynamoDB -> Tables -> Create table.
  2. Table name: studentData
  3. Partition key: studentid (type String)
  4. No sort key.
  5. Capacity mode: On-demand.

On-demand means I don't have to guess read/write capacity. I pay per request, which is ideal for a small project with unpredictable traffic.

DynamoDB table overview showing studentData with on-demand capacity

A note on key design: studentid is the only key, so saving a student with an existing ID overwrites that record. That's fine here, but worth knowing. If you need to prevent accidental overwrites, you can add a condition to the write so it fails when the ID already exists.

Step 2: Create the IAM role for Lambda

Lambda needs permission to touch DynamoDB. Instead of putting credentials in code (never do that), I created an execution role. Lambda assumes this role when it runs, so the permissions travel with the function and nothing secret ends up in your source code.

  1. Open IAM -> Roles -> Create role.
  2. Trusted entity: AWS service -> Lambda.
  3. Attach a DynamoDB policy.
  4. Name it lambda-dynamodb-write-role.

IAM role with DynamoDB permissions attached

To get moving quickly I attached the AWS managed AmazonDynamoDBFullAccess policy. It works, but it grants far more than this app needs. The proper least-privilege version is near the end of the post.

Also make sure the role can write logs to CloudWatch. The AWSLambdaBasicExecutionRole managed policy does this, and you will want those logs when debugging. Without it, a failing function just fails silently and you have nothing to look at.

Step 3: Write the Lambda functions

I created two Python functions, both using the role from Step 2. I used Lambda proxy integration in API Gateway, so each function receives the full HTTP request in event and must return a response with a status code, headers and a string body.

That last part is easy to get wrong: with proxy integration, API Gateway doesn't build the response for you. Whatever your function returns is exactly what the browser gets, including the CORS headers.

The code below shows the approach I used; adjust names to match your own setup.

POST function: save a student

This function reads the JSON body, checks the required fields, validates the age, and writes the item to DynamoDB. Bad input returns a 400, and anything unexpected returns a 500 without leaking internal details to the caller.

import json
import boto3

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("studentData")

HEADERS = {
    "Content-Type": "application/json",
    "Access-Control-Allow-Origin": "*",
}

def response(status, message):
    return {
        "statusCode": status,
        "headers": HEADERS,
        "body": json.dumps({"message": message}),
    }

def lambda_handler(event, context):
    try:
        body = json.loads(event.get("body") or "{}")

        student_id = str(body.get("studentid", "")).strip()
        name = str(body.get("name", "")).strip()
        student_class = str(body.get("class", "")).strip()
        age = body.get("age")

        if not student_id or not name:
            return response(400, "studentid and name are required")

        item = {
            "studentid": student_id,
            "name": name,
            "class": student_class,
        }

        # Only store age if it was provided, and make sure it's a number
        if age not in (None, ""):
            try:
                item["age"] = int(age)
            except (TypeError, ValueError):
                return response(400, "age must be a whole number")

        table.put_item(Item=item)
        return response(200, "Student Data Saved!")

    except Exception as e:
        print(f"Error: {e}")
        return response(500, "Internal server error")
Enter fullscreen mode Exit fullscreen mode

A few things worth pointing out:

  • The response() helper keeps the headers in one place, so every return path includes the CORS header.
  • event.get("body") or "{}" protects against an empty body.
  • The print in the except block is what shows up in CloudWatch Logs, so keep it.

GET function: fetch all students

DynamoDB returns numbers as Python Decimal, which json.dumps can't serialize. This tripped me up, so the GET function needs a small converter. It also pages through the scan results, because a single scan call returns at most 1 MB of data.

import json
from decimal import Decimal
import boto3

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("studentData")

HEADERS = {
    "Content-Type": "application/json",
    "Access-Control-Allow-Origin": "*",
}

def to_json(value):
    if isinstance(value, Decimal):
        return int(value) if value % 1 == 0 else float(value)
    raise TypeError(f"Cannot serialize {type(value)}")

def lambda_handler(event, context):
    try:
        response = table.scan()
        items = response.get("Items", [])

        # scan returns at most 1 MB per call, so keep paging
        while "LastEvaluatedKey" in response:
            response = table.scan(ExclusiveStartKey=response["LastEvaluatedKey"])
            items.extend(response.get("Items", []))

        return {
            "statusCode": 200,
            "headers": HEADERS,
            "body": json.dumps(items, default=to_json),
        }

    except Exception as e:
        print(f"Error: {e}")
        return {
            "statusCode": 500,
            "headers": HEADERS,
            "body": json.dumps({"message": "Internal server error"}),
        }
Enter fullscreen mode Exit fullscreen mode

Scan reads the whole table, which is fine for a small project but gets slow and expensive as data grows. I come back to this in the improvements list at the end.

Test before you connect anything. Before wiring up the API, test each function with a test event in the Lambda console. If it fails here, API Gateway will only make it harder to see why. For the POST function, a test event with an event.body containing a JSON string works well.

Step 4: Build the REST API in API Gateway

API Gateway is the public front door. It gives the browser an HTTPS URL and forwards each request to the right Lambda function.

  1. Open API Gateway -> Create API -> REST API.
  2. Name it StudentDataAPI.
  3. On the root resource (/), create two methods:
    • GET -> Lambda integration -> your GET function
    • POST -> Lambda integration -> your POST function
  4. Turn on Lambda proxy integration for both.
  5. Use Enable CORS on the resource. This creates the OPTIONS method (a Mock integration) that answers the browser's preflight request.
  6. Deploy API to a stage (for example prod).

After this step you should see three methods on the root resource: GET and POST (Lambda) and OPTIONS (Mock).

API Gateway resources and methods showing GET, OPTIONS and POST

After deploying, you get an invoke URL that looks like this:

https://<api-id>.execute-api.us-east-1.amazonaws.com/prod
Enter fullscreen mode Exit fullscreen mode

You can sanity-check it straight from a terminal before touching the frontend. The first command saves a student, the second lists everyone:

curl -X POST "https://<api-id>.execute-api.us-east-1.amazonaws.com/prod" \
  -H "Content-Type: application/json" \
  -d '{"studentid":"1","name":"Test Student","class":"Tenth","age":21}'

curl "https://<api-id>.execute-api.us-east-1.amazonaws.com/prod"
Enter fullscreen mode Exit fullscreen mode

If both commands work, the backend is done. Everything from here on is about the browser.

About CORS (read this part)

This is where I lost the most time. The API worked perfectly in the console test and in curl, but the browser refused to show the result.

The reason is that the page and the API live on different origins (a CloudFront domain and an execute-api domain). Browsers send a preflight OPTIONS request before a cross-origin POST with a JSON body, and three things need to line up:

  1. The OPTIONS method must exist and return the right Access-Control-Allow-* headers.
  2. Access-Control-Allow-Headers must include Content-Type. The JSON Content-Type header is exactly what triggers the preflight in the first place.
  3. Your Lambda responses must also include Access-Control-Allow-Origin, because with proxy integration API Gateway passes your headers through as-is.

Miss any one of them and you'll see a CORS error in the browser console even though the API is healthy. That's what makes it so confusing: curl doesn't enforce CORS, so it keeps working while the browser fails.

Also remember that changes to API Gateway only take effect after you redeploy the stage.

Step 5: The frontend

The page is plain HTML and JavaScript. No framework needed. It has one form, two buttons, and a table that gets filled in from the API response.

The code below is the core logic. The screenshots in this post come from a version with extra CSS styling, so yours will look plainer until you add your own styles.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Save and View Student Data</title>
</head>
<body>
  <h1>Save and View Student Data</h1>

  <label>Student ID:</label>
  <input id="studentid" />
  <label>Name:</label>
  <input id="name" />
  <label>Class:</label>
  <input id="class" />
  <label>Age:</label>
  <input id="age" type="number" />

  <button onclick="saveStudent()">Save Student Data</button>
  <p id="status"></p>

  <button onclick="loadStudents()">View all Students</button>
  <table>
    <thead>
      <tr><th>Student ID</th><th>Name</th><th>Class</th><th>Age</th></tr>
    </thead>
    <tbody id="rows"></tbody>
  </table>

  <script>
    const API_URL = "https://<api-id>.execute-api.us-east-1.amazonaws.com/prod";

    async function saveStudent() {
      const payload = {
        studentid: document.getElementById("studentid").value,
        name: document.getElementById("name").value,
        class: document.getElementById("class").value,
        age: document.getElementById("age").value,
      };

      try {
        const res = await fetch(API_URL, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(payload),
        });
        const data = await res.json();
        document.getElementById("status").textContent = data.message;
      } catch (err) {
        document.getElementById("status").textContent = "Could not reach the API.";
      }
    }

    async function loadStudents() {
      const rows = document.getElementById("rows");
      rows.innerHTML = "";

      try {
        const res = await fetch(API_URL);
        if (!res.ok) throw new Error("Request failed");
        const students = await res.json();

        students.forEach((s) => {
          const tr = document.createElement("tr");
          [s.studentid, s.name, s.class, s.age].forEach((value) => {
            const td = document.createElement("td");
            td.textContent = value ?? "";   // textContent avoids HTML injection
            tr.appendChild(td);
          });
          rows.appendChild(tr);
        });
      } catch (err) {
        document.getElementById("status").textContent = "Could not load students.";
      }
    }
  </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Replace the API_URL value with your own invoke URL from Step 4.

Using textContent instead of innerHTML for the table cells is a small habit that matters: it stops a student "name" like <script>...</script> from running in someone's browser.

Here is the form after saving a student. The "Student Data Saved!" message comes straight from the Lambda response:

Saving a student and seeing the confirmation message

And here is the table after clicking View all Students:

The table listing all saved students

Notice that student 2 appears before student 1. That isn't a bug in the page, it's how DynamoDB's Scan behaves, and I come back to it in the problems section.

Top comments (0)