Thumbnail

How to Build a Book Library (CRUD App) with Nextjs 14, AWS Lambda, AWS API Gateway and DynamoDb

2024-10-05

||

15 mins read

Intro:

app ui

In this tutorial, We will build a book library in nextjs and dynamodb as the database which will be connected with aws lambda and api gateway. You can see the demo of the in the video below the title in this page.

We will have three pages in the app

      • home page which shows all the books we have in the database.
      • add page to add a new book in the database.
      • and a id page where we can view, edit and delete the book we have selected.

Workflow of the App:

data flow of the app

First lets look at data flow in our app:

      • A request will be made from server action in nextjs to the api gateway.
      • The api gateway will trigger the Lambda function.
      • Based on the type of request the Lambda function will do the crud operation in the dynamodb.

The way we will build the app:

      • First we will create a nextjs app and build our ui.
      • Then we will create our table NextjsBookTable in the dynamodb
      • Then we will create the Lambda function and add the required the code to do the crud operations.
      • And we will create the API Gateway. then, create the routes we need and attach them to the Lambda function.
      • And Finally we will write the server actions in Nextjs to communicate with the API Gateway.

Step 1: Setting up Nextjs:

First create a basic nextjs app using the following command.

npx create-next-app@latest dynamo-booklib

Use the following options.

create next app options

Once created, open the project in vs code and we need to change the default code provided by nextjs.

Remove all the code in global.css except the first three tailwind css.

@tailwind base;
@tailwind components;
@tailwind utilities;

global.css

Now we need to add our colors in the tailwind.config.ts .

import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {
      colors: {
        "text-color": "#0f212a",
        "text-hover": "#162832",
        "btn-color": "#023047",
      },
    },
  },
  plugins: [],
};
export default config;

tailwind.config.ts

In layout.tsx lets add the background color and the text color of the app.

import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";

const geistSans = localFont({
  src: "./fonts/GeistVF.woff",
  variable: "--font-geist-sans",
  weight: "100 900",
});
const geistMono = localFont({
  src: "./fonts/GeistMonoVF.woff",
  variable: "--font-geist-mono",
  weight: "100 900",
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased bg-blue-100 text-text-color`}
      >
        {children}
      </body>
    </html>
  );
}

app/layout.tsx

In page.tsx of app directory lets remove the default template.

export default function Home() {
  return (
    <div className="min-h-screen">
    </div>
  );
}

app/page.tsx

Now lets create a add page which we will use to create a book, Create a add folder in app directory and create a page.tsx.

"use client"

function AddPage() {
  return (
    <div className="min-h-screen">
    </div>
  );
}

export default AddPage

app/add/page.tsx

lets create a id page which we will use to view, edit and delete a book, Create a folder named [id] in the app directory.

"use client"

function BookPage() {
  return (
    <div className="min-h-screen">
    </div>
  );
}

export default BookPage

app/[id]/page.tsx

Step 2: Creating a Header Component:

Lets create a components folder in the parent directory. First lets create a Header Component that we can use in all the pages. It is a simple header with a title and two nav links, one is for the home page and another is for the add page.

import Link from "next/link";

function Header() {
  return (
    <header className="flex justify-between items-center py-6 px-10 shadow-md">
      <h1 className="text-3xl font-bold">Book Library</h1>
      <nav>
        <ul className="flex space-x-6">
          <li className="flex items-center">
            <Link
              className="hover:text-text-hover"
              href={"/"}
            >
              Home
            </Link>
          </li>
          <li>
            <button className="py-2 px-4 bg-btn-color text-white rounded hover:bg-text-hover transition duration-200">
              <Link href={"/add"}>Add Book</Link>
            </button>
          </li>
        </ul>
      </nav>
    </header>
  );
}

export default Header;

components/Header.tsx

Step 3: Creating a BookCard Component:

Now lets create a BookCard component which showcase a single book.

import { IBook } from "@/types";
import Link from "next/link";

function BookCard({ book }: { book: IBook }) {
  return (
    <div className="p-6 bg-slate-100 rounded-lg shadow-lg">
      <h3 className="text-2xl font-semibold">
        {book.title}
      </h3>
      <p className="text-lg text-gray-600">
        by {book.author}
      </p>
      <div className="mt-2 text-xl font-bold">
        {book.price}
      </div>
      <button className="mt-4 py-2 px-4 bg-btn-color text-white rounded hover:bg-text-hover transition duration-200">
        <Link href={`/${book.id}`}>View Book</Link>
      </button>
    </div>
  );
}

export default BookCard;

components/Bookcard.tsx

Step 4: Creating Table in DynamoDB

aws dynamodb console

Once you go login into your aws console click on create table.

create table in aws

Enter your table and enter id in the Partition Key field with Number as its datatype, scroll down and click create table.

After few seconds your table will be created and we can move on to the lambda function.

Step 5: Creating Lambda Function

lambda console in aws

Once you go into your lambda dashboard, click create function.

create lambda function image

Give a name to your lambda function and toggle the change default execution role and select "create a new role from AWS policy templates" as below.

create lambda function image

Give a Role name and make sure to select "Simple microservice permissions" in policy templates, this give the lambda function permission to access the table in dynamodb and click create function.

In your lambda function you can see the code editor below, In that add the code given below and change the tablename to your table's name and make sure that you have clicked 'deploy' once you pasted the code.

editing code in lambda function
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient,
  ScanCommand,
  PutCommand,
  GetCommand,
  DeleteCommand,
} from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const dynamo = DynamoDBDocumentClient.from(client);
const tablename = "booksLibrary"; 

const getBook = async (id) => {
  const result = await dynamo.send(
    new GetCommand({
      TableName: tablename,
      Key: { id: Number(id) },
    })
  );
  return result.Item || { message: "not found" };
};

const getAllBooks = async () => {
  const result = await dynamo.send(
    new ScanCommand({
      TableName: tablename,
    })
  );
  return result.Items;
};

const putBook = async (book) => {
  await dynamo.send(
    new PutCommand({
      TableName: tablename,
      Item: {
        id: Number(book.id),
        price: book.price,
        author: book.author,
        description: book.description,
        title: book.title,
      },
    })
  );
  return `PUT book ${book.id}`;
};

const deleteBook = async (id) => {
  await dynamo.send(
    new DeleteCommand({
      TableName: tablename,
      Key: { id: Number(id) },
    })
  );
  return `Deleted Book ${id}`;
};

export const handler = async (event) => {
  let body;
  let statusCode = 200;
  const headers = {
    "Content-Type": "application/json",
  };

  try {
    switch (event.routeKey) {
      case "GET /books/{id}":
        body = await getBook(event.pathParameters.id);
        break;
      case "GET /books":
        body = await getAllBooks();
        break;
      case "PUT /books":
        const data = JSON.parse(event.body);
        body = await putBook(data);
        break;
      case "DELETE /books/{id}":
        body = await deleteBook(event.pathParameters.id);
        break;
      default:
        throw new Error(
          `unSupported route: ${event.routeKey}`
        );
    }
  } catch (error) {
    statusCode = 400;
    body = error.message;
  } finally {
    body = JSON.stringify(body);
  }

  return {
    statusCode,
    body,
    headers,
  };
};

The handler function you see at the last part of the code is the entry point in our lambda function. Then it will call the required function based on the request we have made using switch statement and returns the data.

We have four functions, getBook function will get id as a parameter and fetches the book in our table and returns that book.

getAllBooks function will return all the books present in our table.

putBook is used to create a new book inside table .i.e database.

deleteBook will delete the book based on the provided id.

If you want to test lambda function, you can see how in my video that i have given at the top of the post.

Again, if you modify any part of the code, make sure that you have deployed it by clicking the deploy button. Now, we can move on to creating the api gateway.

Step 6: Creating Http API Gateway

Open api gateway in aws and click on create API.

create api gateway image

Click Build in HTTP API.

create api gateway image

Give an API name and click next.

create api gateway image

click next in the configure routes since we will add it later and click next in define stages as well. the ui will look something like below and click create.

create api gateway image

Once created click on create in the routes page to create a new route.

Now select get and add books after the '/' and click create.

In the same create three more routes as below

  • GET /books/{id}
  • PUT /books
  • DELETE /books/{id}

Once you added the routes it will look like this.

Now we need to add an integration to these routes, For that go to integration. You can see it in the sidebar and go to manage integrations and click create.

add routes to api gateway image

Now select the route in the drop down and select "Lambda function" in the integration type and select the lambda function you have created in the Lambda function and scoll down and click Create.

add routes to api gateway image

Now if you go to integrations again, you can see "AWS Lambda" to the route we have added.

add routes to api gateway image

Now that we have created the integration we can just attach it to the other routes. Just click on a route and select your integration in the "Choose an existing integration" and click attack integration.

add routes to api gateway image

Now, repeat the above step again and attach integration to the other two routes as well.

add routes to api gateway image

Now we have created our api gateway completely. Click on the API: in the sidebar and copy the Default endpoint which will be the url of your api.

api gateway image

Now to check paste your url in the browser with /books at the end like API_URL/books. You will be able to see an empty array []. which means our api gateways works and since we dont have any data in dynamodb we are receiving an empty array.

Keep this url secret, dont share it online or anywhere public. since we have not added any authentication anyone can access the data. so as long as you dont share the api url you are safe.

you can add authentication with iam or roles or many other options,But i am going to stick with basics and not add authentication, so that we can learn the functionality first.

Step 7: Creating a type for Book:

lets create a type.ts page within the parent directory and add our type.

export interface IBook {
  id: number;
  title: string;
  author: string;
  price: number;
  description: string;
}

type.ts

Step 8: Adding env variable:

create a .env.local file in the parent directory and add your api url in it.

AWS_API_URL=

.env.local

Step 9: Creating Server Actions:

Let's create a folder named actions.

We will have two actions file one with "server-only" which can be called only in the server components. and another file with "use server" which calls the function in the "server-only" action file.

lets create a data.ts in the actions directory.

import "server-only";
import { IBook } from "@/types";

export const getBooks = async () => {
  try {
    const response = await fetch(
      `${process.env.AWS_API_URL}/books`,
      {
        cache: "no-store",
      }
    ).then(async function (res) {
      const status = res.status;
      const data = await res.json();
      return { data, status };
    });
    return response;
  } catch (error) {
    console.log("Error: ", error);
    throw new Error("Failed to fetch Books.");
  }
};

export const getBook = async (id: number) => {
  try {
    const response = await fetch(
      `${process.env.AWS_API_URL}/books/${id}`
    ).then(async function (res) {
      const status = res.status;
      const data = await res.json();
      return { data, status };
    });
    return response;
  } catch (error) {
    console.log("Error: ", error);
    throw new Error("Failed to fetch Book.");
  }
};

export const putBook = async (data: IBook) => {
  try {
    const response = await fetch(
      `${process.env.AWS_API_URL}/books`,
      {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(data),
      }
    );

    if (!response.ok) {
      throw new Error(
        `Failed to create the Book ${response.status}`
      );
    }

    return await response.json();
  } catch (error) {
    console.log("Error: ", error);
    throw new Error("Failed to create the Book.");
  }
};

export const deleteBook = async (id: number) => {
  try {
    const response = await fetch(
      `${process.env.AWS_API_URL}/books/${id}`,
      {
        method: "DELETE",
        headers: {
          "Content-Type": "application/json",
        },
      }
    );

    if (!response.ok) {
      throw new Error(
        `Failed to delete the Book ${response.status}`
      );
    }

    return await response.json();
  } catch (error) {
    console.log("Error: ", error);
    throw new Error("Failed to delete the Book.");
  }
};

actions/data.ts

Now, lets create a actions.ts page in the actions directory

"use server";

import { IBook } from "@/types";
import { deleteBook, getBook, putBook } from "./data";

export const getBookFromDB = async (id: number) => {
  const res = await getBook(id);
  return res;
};

export const putBookInDB = async (data: IBook) => {
  const res = await putBook(data);
  return res;
};

export const deleteBookInDB = async (id: number) => {
  const res = await deleteBook(id);
  return res;
};

actions/actions.ts

Step 10: Creating the home page

import { getBooks } from "@/actions/data";
import BookCard from "@/components/BookCard";
import Header from "@/components/Header";
import { IBook } from "@/types";

export const dynamic = "force-dynamic";

export default async function Home() {
  const books: IBook[] = (await getBooks()).data;

  return (
    <div className="min-h-screen">
      <Header />

      <main className="py-12 px-8">
        <h2 className="text-4xl font-semibold mb-8">
          Explore Our Collections
        </h2>

        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
          {books.map((book, idx) => (
            <BookCard key={idx} book={book} />
          ))}
        </div>
      </main>
    </div>
  );
}

app/page.tsx

Step 12: Creating add page

"use client";

import { putBookInDB } from "@/actions/actions";
import Header from "@/components/Header";
import { IBook } from "@/types";
import { useRouter } from "next/navigation";
import { useState } from "react";

function AddPage() {
  const [book, setBook] = useState<IBook>({
    id: Math.floor(1000 + Math.random() * 9000),
    title: "",
    author: "",
    price: 0,
    description: "",
  });
  const [error, setError] = useState<string | null>(null);
  const router = useRouter();

  const handleChange = (
    e: React.ChangeEvent<
      HTMLInputElement | HTMLTextAreaElement
    >
  ) => {
    const { name, value } = e.target;
    if (book) {
      const newBook: IBook = {
        ...book,
        [name]:
          name === "price" ? parseFloat(value) : value,
      };
      setBook(newBook);
    }
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await putBookInDB(book);
      router.push("/");
      router.refresh();
    } catch (error) {
      console.log(error);
      setError("Failed to edit book");
    }
  };

  return (
    <div className="min-h-screen">
      <Header />
      {error && (
        <div className="my-5 w-full max-w-lg mx-auto bg-red-400 p-4 rounded-lg">
          {error}
        </div>
      )}
      <form
        onSubmit={handleSubmit}
        className="my-5 w-full max-w-lg mx-auto"
      >
        <h2 className="text-2xl font-bold mb-6">
          Add Book
        </h2>

        {/* title */}
        <div className="mb-4">
          <label
            htmlFor="title"
            className="block font-semibold"
          >
            Title
          </label>
          <input
            type="text"
            id="title"
            name="title"
            value={book.title}
            onChange={handleChange}
            placeholder="Enter Book Title"
            className="w-full p-2 mt-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-text-color"
            required
          />
        </div>

        {/* author */}
        <div className="mb-4">
          <label
            htmlFor="author"
            className="block font-semibold"
          >
            Author
          </label>
          <input
            type="text"
            id="author"
            name="author"
            value={book.author}
            onChange={handleChange}
            placeholder="Enter Author's Name"
            className="w-full p-2 mt-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-text-color"
            required
          />
        </div>

        {/* price */}
        <div className="mb-4">
          <label
            htmlFor="price"
            className="block font-semibold"
          >
            Price
          </label>
          <input
            type="number"
            id="price"
            name="price"
            step="0.01"
            value={book.price}
            onChange={handleChange}
            placeholder="Enter Book Price"
            className="w-full p-2 mt-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-text-color"
            required
          />
        </div>

        {/* description */}
        <div className="mb-4">
          <label
            htmlFor="description"
            className="block font-semibold"
          >
            Description
          </label>
          <textarea
            id="description"
            name="description"
            value={book.description}
            onChange={handleChange}
            placeholder="Enter Book Description"
            className="w-full p-2 mt-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-text-color"
            rows={4}
            required
          />
        </div>
        <button
          type="submit"
          className="w-full py-3 bg-btn-color text-white rounded hover:bg-text-hover transition duration-200"
        >
          Add Book
        </button>
      </form>
    </div>
  );
}

export default AddPage;

add/page.tsx

Step 12: Creating the [id] page:

"use client";

import { useEffect, useState } from "react";
import { IBook } from "@/types";
import Header from "@/components/Header";
import { useRouter } from "next/navigation";
import {
  deleteBookInDB,
  getBookFromDB,
  putBookInDB,
} from "@/actions/actions";

function BookPage({ params }: { params: { id: number } }) {
  const [book, setBook] = useState<IBook | null>(null);
  const [error, setError] = useState<string | null>(null);
  const router = useRouter();

  const handleChange = (
    e: React.ChangeEvent<
      HTMLInputElement | HTMLTextAreaElement
    >
  ) => {
    const { name, value } = e.target;
    if (book) {
      const newBook: IBook = {
        ...book,
        [name]:
          name === "price" ? parseFloat(value) : value,
      };
      setBook(newBook);
    }
  };

  const handleEditSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      if (book) {
        await putBookInDB(book);
        router.push("/");
        router.refresh();
      }
    } catch (error) {
      console.log(error);
      setError("Failed to edit book");
    }
  };

  const handleDeleteSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      if (book) {
        await deleteBookInDB(params.id);
        router.push("/");
        router.refresh();
      }
    } catch (error) {
      console.log(error);
      setError("Failed to delete book");
    }
  };

  useEffect(() => {
    const fetchBook = async () => {
      const fetchedBook = await getBookFromDB(params.id);
      if (fetchedBook.data.message == "not found") {
        router.push("/");
      }
      setBook(fetchedBook.data);
    };

    fetchBook();
  }, [params.id, router]);

  return (
    <div className="min-h-screen">
      <Header />
      {error && (
        <div className="my-5 w-full max-w-lg mx-auto bg-red-400 p-4 rounded-lg">
          {error}
        </div>
      )}
      {book ? (
        <div>
          <form
            onSubmit={handleEditSubmit}
            className="my-5 w-full max-w-lg mx-auto"
          >
            <h2 className="text-2xl font-bold mb-6">
              Edit Book
            </h2>

            {/* title */}
            <div className="mb-4">
              <label
                htmlFor="title"
                className="block font-semibold"
              >
                Title
              </label>
              <input
                type="text"
                id="title"
                name="title"
                value={book.title}
                onChange={handleChange}
                placeholder="Enter Book Title"
                className="w-full p-2 mt-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-text-color"
                required
              />
            </div>

            {/* author */}
            <div className="mb-4">
              <label
                htmlFor="author"
                className="block font-semibold"
              >
                Author
              </label>
              <input
                type="text"
                id="author"
                name="author"
                value={book.author}
                onChange={handleChange}
                placeholder="Enter Author's Name"
                className="w-full p-2 mt-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-text-color"
                required
              />
            </div>

            {/* price */}
            <div className="mb-4">
              <label
                htmlFor="price"
                className="block font-semibold"
              >
                Price
              </label>
              <input
                type="number"
                id="price"
                name="price"
                step="0.01"
                value={book.price}
                onChange={handleChange}
                placeholder="Enter Book Price"
                className="w-full p-2 mt-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-text-color"
                required
              />
            </div>

            {/* description */}
            <div className="mb-4">
              <label
                htmlFor="description"
                className="block font-semibold"
              >
                Description
              </label>
              <textarea
                id="description"
                name="description"
                value={book.description}
                onChange={handleChange}
                placeholder="Enter Book Description"
                className="w-full p-2 mt-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-text-color"
                rows={4}
                required
              />
            </div>
            <button
              type="submit"
              className="w-full py-3 bg-btn-color text-white rounded hover:bg-text-hover transition duration-200"
            >
              Edit Book
            </button>
          </form>
          <form
            onSubmit={handleDeleteSubmit}
            className="my-5 w-full max-w-lg mx-auto"
          >
            <button
              type="submit"
              className="w-full py-3 bg-red-700 text-white rounded hover:bg-red-600 transition duration-200"
            >
              Delete Book
            </button>
          </form>
        </div>
      ) : (
        <div> loading ...</div>
      )}
    </div>
  );
}

export default BookPage;

[id]/page.tsx

Summary:

Building this Book Library app with Next.js 14, AWS DynamoDB, AWS Lambda and AWS API Gateway has given us a solid foundation for creating full-stack apps. Using Next.js server actions and AWS, we can manage data smoothly and scale our app easily. I hope this guide helps you understand the basics and encourages you to try out your own projects!