API implementation

API Implementation

TypeScript Skills

Now that you have a solid foundation in JavaScript, it's time to learn TypeScript. TypeScript is a superset of JavaScript that adds type annotations and other features to the language. It's a great way to catch errors early and make your code more readable.

Instead of providing the usual documentation, please read through TypeScript for JavaScript Programmers (opens in a new tab). It's a short guide that will help you understand the basics of TypeScript.

To add TypeScript in Next.js is easy! When creating a new project with npx create-next-app@latest, just say "yes" when asked if you want to use TypeScript.

API Implementation with Next.js

Definition of REST and RESTful APIs

REST stands for Representational State Transfer, a software architectural style that defines a set of constraints for creating web services. Web services that adhere to these constraints are called RESTful APIs.

RESTful APIs are based on the following principles:

  • Client-Server Architecture: The client is responsible for the user interface and user experience, and the server manages the data. The separation allows development on both sides to progress independently.

  • Stateless: Each request from a client to a server must contain all the necessary information to understand and respond to the request. The server should not store anything about the latest HTTP request the client made. It must be provided with every request.

  • Cacheable: Responses from the server can be cached by the client to improve performance.

  • Uniform Interface: The method of communication between the client and server is uniform, meaning the same set of constraints is applied to the interaction.

A RESTful API uses HTTP methods, which are typically GET, POST, PUT, PATCH, and DELETE, to perform operations. These methods correspond to read, create, update, and delete (or CRUD) operations, respectively.

In the context of Next.js, we'll use these principles to build our API routes, allowing clients to interact with our server to create, read, update, and delete data.

How to Create an API Route

Creating an API route in Next.js is as simple as creating a route.ts file in a directory. Although a route.js can go in any folder, it typically would be named /api. For example, if you create a file called route.ts in the /app/api/hello directory, you can access it at the /api/hello endpoint.

// /app/api/hello/route.ts
export async function GET() {
  return new Response("Hello, world!");
}

Consuming the API route from a component is as simple as making a request to the /api/hello endpoint.

const response = await fetch("/api/hello");

For each HTTP verb (GET, POST, PUT, PATCH, DELETE), you can export a function with the same name. For example, if you want to create a POST route, you can export a function called POST. The function takes two arguments: a Request object a params prop. The Request object contains information about the request, and the params prop contains information about the request parameters.

// update partial information of a user
// request is a Request object and contains information about the request, e.g. request body
// params is an object containing information about the request parameters, e.g. params.id
export async function PATCH(
  request: Request,
  { params }: { params: { id: number } }
) {
  const id = params.id;
  const newUser = await request.json();
 
  if (newUser.name) {
    // update name in database
    // const result = await db.query("UPDATE users SET name = ? WHERE id = ?", newUser.name, id);
  }
}

The return value of the function is a Response object. The Response object contains information about the response, e.g. the response body and status code.

return new Response(JSON.stringify(newUser), { status: 201 });

In this example, we're returning a response with a status code of 201 (which means a new resource was successfully created) along with the newly created user data.

POST Requests

The HTTP POST method is used to send data to a server to create a new resource. In a RESTful API, we typically use POST requests to create new data.

Let's create an API route in Next.js that handles a POST request to create a new user. For the sake of this example, we'll use a simple array to store our users.

export async function POST(request: Request) {
  const newUser = await request.json();
 
  // store in database
  // ...
  // update id
  newUser.id = 1;
 
  return new Response(JSON.stringify(newUser), { status: 201 });
}

In this example, when a POST request is made to /api/users, we extract the newUser from the request body, store the user in the database, update the user's id, and return a response with a status of 201 (which means a new resource was successfully created) along with the newly created user data.

GET Requests

The HTTP GET method is used to request data from a specified resource. In a RESTful API, we typically use GET requests to fetch data.

// fetch list of users
export async function GET() {
  // fetch users from database
  // const users = await db.query("SELECT * FROM users");
  const users: User[] = [
    { id: 1, name: "Abe", age: 30 },
    { id: 2, name: "Bob", age: 20 },
  ];
 
  console.log(`fetch users`);
  return new Response(JSON.stringify(users), { status: 200 });
}

In this example, when a GET request is made to /api/users, we fetch the users from the database and return a response with a status of 200 (which means the request was successful) along with the users data.

PUT Requests

The HTTP PUT method is used to update an existing resource. In a RESTful API, we typically use PUT requests to update data.

// update all the information of a user
export async function PUT(
  request: Request,
  { params }: { params: { id: number } }
) {
  const newUser = await request.json();
 
  const id = params.id;
 
  // update user in database
  // const result = await db.query("UPDATE users SET name = ?, age = ? WHERE id = ?", newUser.name, newUser.age, id);
 
  console.log(`(full) update user ${id}`);
  return new Response(null, { status: 204 });
}

In this example, when a PUT request is made to /api/users/:id, we extract the newUser from the request body, update the user in the database, and return a response with a status of 204 (which means the request was successful but there is no content to return) along with the updated user data.

PATCH Requests

The HTTP PATCH method is used to update partial information of an existing resource.

// update partial information of a user
export async function PATCH(
  request: Request,
  { params }: { params: { id: number } }
) {
  const id = params.id;
  const newUser = await request.json();
 
  if (newUser.name) {
    // update name in database
    // const result = await db.query("UPDATE users SET name = ? WHERE id = ?", newUser.name, id);
  }
 
  if (newUser.age) {
    // update age in database
    // const result = await db.query("UPDATE users SET age = ? WHERE id = ?", newUser.age, newUser.id);
  }
 
  console.log(`(partial) update user ${id}`);
  return new Response(null, { status: 204 });
}

In this example, when a PATCH request is made to /api/users/:id, we extract the newUser from the request body, update the user in the database, and return a response with a status of 204 (which means the request was successful but there is no content to return).

DELETE Requests

The HTTP DELETE method is used to delete an existing resource.

// delete a user
export async function DELETE(
  request: Request,
  { params }: { params: { id: number } }
) {
  const id = params.id;
 
  // delete user in database
  // const result = await db.query("DELETE FROM users WHERE id = ?", newUser.id);
 
  console.log(`delete user ${id}`);
  return new Response(null, { status: 204 });
}

In this example, when a DELETE request is made to /api/users/:id, we delete the user from the database and return a response with a status of 204 (which means the request was successful but there is no content to return).

Debugging API Routes

Several tools exist to help you debug your API routes. The most common one is Postman (opens in a new tab), which is a free tool that allows you to test your API routes and see the responses. VS Code has several extensions that can help you debug your API routes, e.g. REST Client (opens in a new tab).

Validation

Validation is the process of checking if data is valid. It is an important part of any application because it helps prevent errors and improve the user experience. In this section, we will discuss how to validate data using a library called zod.

zod

Zod is a TypeScript-first library for building schemas and validating JavaScript and TypeScript data. It supports complex object validation, nested schemas, transformation, and more.

To install zod, run the following command in your project directory.

npm install zod

To use zod, import the z object from the zod library.

import { z } from "zod";

The core of Zod is its ability to define schemas to match the shape of our data. Here's a simple example:

import { z } from "zod";
 
const PersonSchema = z.object({
  name: z.string(),
  age: z.number(),
  isMarried: z.boolean().optional(),
});
 
type Person = z.infer<typeof PersonSchema>;

In this example, PersonSchema is a Zod schema that matches an object with a name (string), age (number), and an optional isMarried (boolean) property. The infer keyword is used to create a Person type from the schema.

Validating Data with Zod

We can use the parse method provided by Zod to validate data against our schema:

try {
  const data = { name: "John Doe", age: 30 };
  const person = PersonSchema.parse(data);
 
  console.log(person); // { name: 'John Doe', age: 30 }
} catch (error) {
  console.error(error);
}

In this example, we're attempting to parse an object against our PersonSchema. If the object matches the schema, it's returned by the parse method. If it doesn't match, an error is thrown.

Zod provides a flexible way to ensure your data matches the shape and type you expect, which can help catch errors early and improve code quality.

🌐 Real-World Example

Below is a real-world examples of a Next.js app that is more advanced that the examples presented so far. Nevertheless, it should still be approachable and understandable. We will use this project as a reference for the rest of the course.

For this week, please read through the code of this project and try to understand how it works. You don't need to understand every single line of code, but try to get a general idea of how the code is structured and how the different parts of the app work together.

Iotawise

Original GitHub Repository: https://github.com/redpangilinan/iotawise (opens in a new tab)

Forked GitHub Repositoryhttps://github.com/warsylewicz/iotawise (opens in a new tab)

Live Demo: https://iotawise.rdev.pro/ (opens in a new tab)

Iotawise is an open-source habit tracking app that lets you track daily habits and monitor your activity streaks and progress with little effort. The technology stack includes:

  • Next.js /app dir
  • TypeScript
  • Tailwind CSS
  • shadcn/ui Components
  • NextAuth.js
  • Prisma ORM
  • Zod Validations
  • PlanetScale Database (MySQL)

For this week, let's focus on the API routes of one feature (activities) and how they are used to fetch and update data.

API Routes

The API routes for the activities feature are located in the /api/activities/route.ts (opens in a new tab) and /api/activities/[activityId]/route.ts (opens in a new tab). The first file contains the routes for fetching and creating activities, while the second file contains the routes for updating and deleting activities. The paths are as follows:

PathMethodDescription
/api/activitiesGETFetch all activities
/api/activitiesPOSTCreate a new activity
/api/activities/[activityId]PATCHUpdate an activity
/api/activities/[activityId]DELETEDelete an activity

Fetching Activities

Below is the GET function in /api/activities/route.ts.

export async function GET() {
  try {
    const session = await getServerSession(authOptions);
 
    if (!session) {
      return new Response("Unauthorized", { status: 403 });
    }
 
    // Get all of current user's activities
    const activities = await db.activity.findMany({
      select: {
        id: true,
        name: true,
        description: true,
        colorCode: true,
        createdAt: true,
      },
      where: {
        userId: session.user.id,
      },
    });
 
    return new Response(JSON.stringify(activities));
  } catch (error) {
    return new Response(null, { status: 500 });
  }
}

The function first checks if the user is authenticated. If not, it returns a 403 (Forbidden) status code. If the user is authenticated, it fetches all of the user's activities from the database and returns them as a JSON string. If there is an error, it returns a 500 (Internal Server Error) status code.

The Response constructor is part of the Web Response API (opens in a new tab) and is used to create a response object that can be returned by an API route. The Response constructor takes two arguments: the body of the response and an object containing the response options. In the example above, the body is a JSON string containing the user's activities, and the response options contain the status code.

Ignore the auth and db variables for now. We will discuss them in the next couple of weeks. For now, just know that they are used to authenticate the user and fetch data from the database.

Creating Activities

Below is the POST function in /api/activities/route.ts.

const activityCreateSchema = z.object({
  name: z.string(),
  description: z.string().optional(),
  colorCode: z.string(),
});
 
export async function POST(req: Request) {
  try {
    const session = await getServerSession(authOptions);
 
    if (!session) {
      return new Response("Unauthorized", { status: 403 });
    }
 
    // Create new activity for authenticated user
    const json = await req.json();
    const body = activityCreateSchema.parse(json);
 
    const activity = await db.activity.create({
      data: {
        name: body.name,
        description: body.description,
        colorCode: body.colorCode,
        userId: session.user.id,
      },
      select: {
        id: true,
      },
    });
 
    return new Response(JSON.stringify(activity));
  } catch (error) {
    if (error instanceof z.ZodError) {
      return new Response(JSON.stringify(error.issues), { status: 422 });
    }
 
    return new Response(null, { status: 500 });
  }
}

The function first checks if the user is authenticated. If not, it returns a 403 (Forbidden) status code. If the user is authenticated, it parses the request body and validates it using the activityCreateSchema schema. If the request body is invalid, it returns a 422 (Unprocessable Entity) status code along with the validation errors. If the request body is valid, it creates a new activity in the database and returns the activity ID as a JSON string. If there is an error, it returns a 500 (Internal Server Error) status code.

The Request constructor is part of the Web Request API (opens in a new tab) and is used to create a request object that can be passed to an API route. The Request constructor takes two arguments: the request body and an object containing the request options. In the example above, the request body is a JSON string containing the activity data, and the request options contain the request method.

The zod library is used to validate the request body. The activityCreateSchema schema defines the shape of the request body and the types of its properties. If the request body does not match the schema, the zod library throws a z.ZodError error. The z.ZodError error contains an issues property that contains the validation errors.

Updating Activities

Below are the PATCH and DELETE functions in /api/activities/[activityId]/route.ts.

const routeContextSchema = z.object({
  params: z.object({
    activityId: z.string(),
  }),
});
 
export async function PATCH(
  req: Request,
  context: z.infer<typeof routeContextSchema>
) {
  try {
    const { params } = routeContextSchema.parse(context);
 
    if (!(await verifyActivity(params.activityId))) {
      return new Response(null, { status: 403 });
    }
 
    const json = await req.json();
    const body = activityPatchSchema.parse(json);
 
    // Update the activity
    await db.activity.update({
      where: {
        id: params.activityId,
      },
      data: {
        name: body.name,
        description: body.description,
        colorCode: body.colorCode,
        updatedAt: new Date(),
      },
    });
 
    return new Response(null, { status: 200 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return new Response(JSON.stringify(error.issues), { status: 422 });
    }
 
    return new Response(null, { status: 500 });
  }
}
 
export async function DELETE(
  req: Request,
  context: z.infer<typeof routeContextSchema>
) {
  try {
    const { params } = routeContextSchema.parse(context);
 
    if (!(await verifyActivity(params.activityId))) {
      return new Response(null, { status: 403 });
    }
 
    // Delete the activity
    await db.activity.delete({
      where: {
        id: params.activityId as string,
      },
    });
 
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return new Response(JSON.stringify(error.issues), { status: 422 });
    }
 
    return new Response(null, { status: 500 });
  }
}

Both functions first check if the user is authenticated. If not, they return a 403 (Forbidden) status code. If the user is authenticated, they parse the request context and validate it using the routeContextSchema schema. If the request context is invalid, they return a 422 (Unprocessable Entity) status code along with the validation errors. If the request context is valid, they verify that the activity ID in the request context belongs to the authenticated user. If the activity ID does not belong to the authenticated user, they return a 403 (Forbidden) status code. If the activity ID belongs to the authenticated user, they parse the request body and validate it using the activityPatchSchema schema. If the request body is invalid, they return a 422 (Unprocessable Entity) status code along with the validation errors. If the request body is valid, they update or delete the activity in the database and return a 200 (OK) or 204 (No Content) status code. If there is an error, they return a 500 (Internal Server Error) status code.

API Consumption

Let's take a look at how the API routes are consumed.

Fetching Activities

For the path /dashboard/activities, we find the following code in /app/dashboard/activities/page.tsx (opens in a new tab).

import { getUserActivities } from "@/lib/api/activities";
 
const activities = await getUserActivities(user.id);
 
<div className="divide-y divide-border rounded-md border">
  {activities.map((activity) => (
    <ActivityItem key={activity.id} activity={activity} />
  ))}
</div>;

It is interesting to note that this page is a server component and does not use the directive "use client". As such, it is rendered on the server and not on the client. This means that the activities variable is populated with data before the page is rendered. In addition, the call to the getUserActivities function is not wrapped in a useEffect hook.

The getUserActivities function is defined in /lib/api/activities.ts (opens in a new tab).

// Fetch all of the activities for the selected user
export async function getUserActivities(
  userId: string
): Promise<UserActivities[]> {
  const results: UserActivities[] = await db.$queryRaw`
    SELECT
      A.id,
      A.name,
      A.description,
      A.color_code AS 'colorCode',
      A.created_at AS 'createdAt',
      SUM(AL.count) AS total_count
    FROM
      activities A
    LEFT JOIN
      activity_log AL ON A.id = AL.activity_id
    WHERE
      A.user_id = ${userId}
    GROUP BY
      A.id, A.name, A.description, A.color_code
    ORDER BY
      total_count DESC;`;
 
  return results.map((result) => ({
    ...result,
    total_count: Number(result.total_count),
  }));
}

Because the getUserActivities function is a server function, it can access the database directly. It uses a raw SQL query to fetch all of the activities for the selected user. It then returns the results as an array of UserActivities objects.

Creating Activities

When the user clicks the "Add Activity" button, the onClick function in the component /components/activity/activity-add-button.tsx (opens in a new tab) is called.

<Button onClick={onClick} disabled={isLoading}>
  {isLoading ? (
    <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
  ) : (
    <Icons.add className="mr-2 h-4 w-4" />
  )}
  <span>Add activity</span>
</Button>
async function onClick() {
  setIsLoading(true);
 
  const response = await fetch("/api/activities", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "New Activity",
      colorCode: "#ffffff",
    }),
  });
 
  if (!response?.ok) {
    setIsLoading(false);
    setShowAddAlert(false);
 
    return toast({
      title: "Something went wrong.",
      description: "Your activity was not created. Please try again.",
      variant: "destructive",
    });
  }
 
  toast({
    description: "A new activity has been created successfully.",
  });
 
  const activity = await response.json();
 
  setIsLoading(false);
  setShowAddAlert(false);
 
  router.push(`/dashboard/activities/${activity.id}/settings`);
  router.refresh();
}

The onClick function first sets the isLoading state to true to disable the button and show a loading spinner. It then makes a POST request to the /api/activities route to create a new activity. If the request is successful, it shows a success toast and redirects the user to the activity settings page. If the request is unsuccessful, it shows an error toast and sets the isLoading state to false to enable the button again.

Updating Activities

When the user edits an activity in the /components/activity/activity-edit-form.tsx (opens in a new tab) component and clicks the "Save changes" button, the onSubmit function is called. The onSubmit function contains the following code.

const response = await fetch(`/api/activities/${activity.id}`, {
  method: "PATCH",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: data.name,
    description: data.description,
    colorCode: color,
  }),
});

The onSubmit function makes a PATCH request to the /api/activities/[activityId] route to update the activity.

Deleting Activities

The delete button can be found in the /components/activity/activity-operations.tsx (opens in a new tab) component. When the user clicks the delete button, the onClick function is called. The onClick function contains the following code.

const deleted = await deleteActivity(activity.id);
 
async function deleteActivity(activityId: string) {
  const response = await fetch(`/api/activities/${activityId}`, {
    method: "DELETE",
  });
}

The onClick function makes a DELETE request to the /api/activities/[activityId] route to delete the activity.

📖 Further Reading