Build a Task Manager API with Next.js

Build a REST API with full CRUD using Next.js Route Handlers.

Introduction

30 Second Summary

Every time you open a to-do app, check your email, or scroll through social media, your device is talking to a server behind the scenes. That server exposes an API that handles creating, reading, updating, and deleting your data.

In this project, you will build a Task Manager API from scratch using Next.js Route Handlers. You will create endpoints that any frontend or mobile app can call to manage tasks.

What You'll Build

A REST API with full CRUD operations (create, read, update, delete) built entirely with Next.js Route Handlers and an in-memory data store.

By the end of this project, you'll have:

  • A working Next.js API that responds to GET, POST, PUT, and DELETE requests for managing tasks.
  • Hands-on experience with dynamic route segments ([id]) for fetching and modifying individual resources.
  • A tested API you can call from any client that returns proper HTTP status codes and JSON responses.
  • Secret Mission: a status toggle endpoint and query parameter filtering to level up your API.

Are there any prerequisites?

You need basic JavaScript or TypeScript knowledge. No prior Next.js or backend experience is needed.

Set Up Your Next.js Project

Your Task Manager API needs a project to live in. Next.js provides a built-in way to create backend API endpoints through its Route Handlers feature, so you get a full project structure ready for API development without any extra tools.

In this step, you will scaffold a new Next.js project using the official CLI and start the development server. This gives you a working foundation with TypeScript and the App Router already configured.

In this step, get ready to:
  • Scaffold a new Next.js project with TypeScript and the App Router.
  • Start the development server and confirm it runs in your browser.
Create a new Next.js project

The create-next-app CLI generates a full project structure with all the configuration files you need. You will pass flags to customize the setup for an API-focused project.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the new project by running this command:
npx create-next-app@latest task-manager-api --typescript --app --no-tailwind --no-src-dir --eslint --use-npm

What does this command do?

  • npx create-next-app@latest runs the official Next.js project generator.
  • task-manager-api is the name of your project folder.
  • --typescript enables TypeScript for type safety and better editor support.
  • --app uses the App Router, which is where Route Handlers live.
  • --no-tailwind skips Tailwind CSS since this is a pure API project with no frontend styling.
  • --no-src-dir keeps your code in the root app/ folder for simplicity.
  • --eslint adds a linter to catch common mistakes.
  • --use-npm tells the CLI to use npm as the package manager.

Because you passed explicit flags, the CLI uses recommended defaults for any remaining options (like the import alias) and skips interactive prompts.

The CLI downloads the template and installs dependencies. This may take a minute depending on your internet speed.

  • Once the scaffolding finishes, move into your new project folder by running:
cd task-manager-api
  • Confirm the project files exist by running:
ls

You should see files including app, package.json, tsconfig.json, and next.config.ts in the output.

Command not found or install failed?

  • Make sure Node.js 20.9 or later is installed by running node --version in your terminal.
  • If you see a permission error on macOS, try prefixing the command with sudo.
  • If npx is not recognized, reinstall Node.js from nodejs.org which bundles npx automatically.

Why is my Next.js project not scaffolding correctly?

Start the development server and verify

With the project scaffolded, you can start the built-in development server to confirm everything works before writing any code.

  • Start the development server by running:
npm run dev
  • Open your browser and visit http://localhost:3000.

You should see the default Next.js welcome page with the Next.js logo and links to documentation.

What does npm run dev do?

This command starts a local development server on port 3000. It watches your files for changes and automatically reloads when you save. Keep this terminal running for the rest of the project.

Don't see the welcome page?

  • Check that your terminal shows no error messages after running npm run dev.
  • Make sure nothing else is already using port 3000. Close any other dev servers you have running.
  • Try opening the URL in a different browser or an incognito window.

Why isn't my Next.js development server starting?

Your Next.js project is ready to go. Next up, you will create a shared data store and build your first Route Handler to serve task data as JSON.

Create the Data Store and First Route Handler

Your Next.js project is scaffolded and the dev server is running. Now it is time to build the foundation of your API.

Every API needs a place to store its data and an endpoint that serves it. In this step, you will create a shared data store module that holds your tasks in memory, then wire up your first Route Handler to return that data as JSON.

In this step, get ready to:
  • Create a shared data store module with a typed Task interface.
  • Build your first GET route handler that returns all tasks.
  • Test the endpoint in your browser to confirm it works.
Create the shared data store

Your route handlers need access to task data. Instead of defining data inside each handler, you will create a single shared module that every route can import.

  • Create a new folder called lib inside your app directory.
  • Inside app/lib, create a new file called tasks.ts.
  • Paste the following code into app/lib/tasks.ts:
export interface Task {
  id: string;
  title: string;
  description: string;
  status: "pending" | "completed";
  createdAt: string;
}

export const tasks: Task[] = [
  {
    id: "1",
    title: "Set up Next.js project",
    description: "Create a new Next.js app with TypeScript",
    status: "completed",
    createdAt: new Date().toISOString(),
  },
  {
    id: "2",
    title: "Build the API",
    description: "Create route handlers for task management",
    status: "pending",
    createdAt: new Date().toISOString(),
  },
];

What does this code do?

The Task interface defines the shape of every task in your API. Each task has an id, title, description, status (either "pending" or "completed"), and a createdAt timestamp.

The tasks array is your in-memory data store. It starts with two sample tasks so you can immediately test your API without inserting data first. This array lives in server memory and resets whenever you restart the dev server.

Why an in-memory data store?

Storing data in a plain array means zero database setup. You can focus entirely on learning how Route Handlers work without worrying about connection strings or migrations.

  • Save app/lib/tasks.ts.

You will not see a change in the browser yet. The next chunk uses this module to serve data through an endpoint.

Seeing a red squiggly line in your editor?

  • Make sure you created the file at app/lib/tasks.ts (not at the project root or inside app/api).
  • Confirm the file extension is .ts (not .tsx or .js).
  • Check that your code has no missing commas between the object properties inside the array.

Why is my data store file showing errors?

Create the GET /api/tasks route handler

In Next.js, the folder structure inside app maps directly to your URL paths. A file called route.ts inside app/api/tasks/ becomes the endpoint at /api/tasks.

  • Inside your app directory, create a folder called api.
  • Inside app/api, create a folder called tasks.
  • Inside app/api/tasks, create a new file called route.ts.
  • Paste the following code into app/api/tasks/route.ts:
import { tasks, Task } from "@/app/lib/tasks";

export async function GET() {
  return Response.json(tasks);
}

What does this code do?

  • The import pulls in the tasks array and the Task type from your shared data store using the @/ path alias (which maps to the project root).
  • Exporting a function named GET tells Next.js to call this function whenever a GET request hits /api/tasks.
  • Response.json(tasks) converts the tasks array to a JSON response with the correct Content-Type header automatically.

What is a Route Handler?

A Route Handler is Next.js's way of creating API endpoints using the Web Request and Response APIs. You define a route.ts file and export named functions for each HTTP method you want to support (GET, POST, PUT, DELETE).

The folder path determines the URL. So app/api/tasks/route.ts becomes /api/tasks in the browser.

  • Save app/api/tasks/route.ts.

Seeing an import error?

  • Check that your data store file is at app/lib/tasks.ts. The import uses @/app/lib/tasks which relies on that exact path.
  • Confirm your tsconfig.json includes the @/* path alias. This is set up automatically by create-next-app.

Help me fix my route handler import error.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for both files. Compare yours against these:

export interface Task {
  id: string;
  title: string;
  description: string;
  status: "pending" | "completed";
  createdAt: string;
}

export const tasks: Task[] = [
  {
    id: "1",
    title: "Set up Next.js project",
    description: "Create a new Next.js app with TypeScript",
    status: "completed",
    createdAt: new Date().toISOString(),
  },
  {
    id: "2",
    title: "Build the API",
    description: "Create route handlers for task management",
    status: "pending",
    createdAt: new Date().toISOString(),
  },
];
import { tasks, Task } from "@/app/lib/tasks";

export async function GET() {
  return Response.json(tasks);
}
Test in the browser

Your dev server is already running, so you can test the new endpoint immediately.

  • Open your browser and navigate to http://localhost:3000/api/tasks.

You should see your two sample tasks returned as a JSON array. The response includes both task objects with their id, title, description, status, and createdAt fields.

Seeing a 404 page instead of JSON?

  • Check the URL carefully. It should be http://localhost:3000/api/tasks (not /api/task without the "s").
  • Verify your file is at app/api/tasks/route.ts. The folder structure must exactly match the URL path.
  • Make sure you saved the file. Unsaved changes are not picked up by the dev server.

My /api/tasks route is returning a 404. Help me fix it.

Your first endpoint is live. Next up, you will add a POST handler to the same route so you can create new tasks through the API.

Handle POST Requests to Create Tasks

Your API can return a list of tasks, but there is no way to add new ones yet. A read-only API is only half the picture.

In this step, you will add a POST handler to your tasks route. It will accept JSON in the request body, validate the input, create a new task object, and return it to the caller with the correct status code.

In this step, get ready to:
  • Add a POST route handler that creates new tasks.
  • Add input validation that rejects requests missing a title.
  • Test both the success and error paths with curl.
Add the POST handler to your tasks route

In REST APIs, a POST request means "create a new resource." Your Route Handler file can export multiple HTTP method functions from the same file. Next.js automatically routes incoming requests to the matching export.

  • In VS Code, open app/api/tasks/route.ts.
  • Add the following POST function below your existing GET function:
export async function POST(request: Request) {
  // Parse the JSON body from the incoming request
  const body = await request.json();

  // Validate that a title was provided
  if (!body.title) {
    return Response.json(
      { error: "Title is required" },
      { status: 400 }
    );
  }

  // Build the new task object
  const newTask: Task = {
    id: crypto.randomUUID(),
    title: body.title,
    description: body.description || "",
    status: "pending",
    createdAt: new Date().toISOString(),
  };

  // Add it to the in-memory array
  tasks.push(newTask);

  // Return the created task with a 201 status
  return Response.json(newTask, { status: 201 });
}

What does this code do?

  • request.json() reads and parses the JSON body the caller sent. This is a standard Web API method, not a Next.js invention.
  • The if (!body.title) check validates that the request includes the one required field. If it is missing, the handler returns a 400 (Bad Request) immediately.
  • crypto.randomUUID() generates a unique ID for the new task. This is built into Node.js 20+, no imports needed.
  • The second argument to Response.json() sets the HTTP status code. A 201 means "Created" and tells the caller the resource was successfully made.
  • Save app/api/tasks/route.ts.
  • You will not see a visible change in the browser because POST requests cannot be triggered from the URL bar. You will test this with curl in the next substep.

Seeing a red underline in your editor?

Make sure your POST function is placed outside and below the closing brace of the GET function. Two exported functions cannot be nested inside each other.

Check that the import at the top of the file still includes both tasks and Task. The POST handler uses the Task type to define newTask.

Help me fix a TypeScript error in my POST route handler.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete app/api/tasks/route.ts file so far:

import { tasks, Task } from "@/app/lib/tasks";

export async function GET() {
  return Response.json(tasks);
}

export async function POST(request: Request) {
  const body = await request.json();

  if (!body.title) {
    return Response.json(
      { error: "Title is required" },
      { status: 400 }
    );
  }

  const newTask: Task = {
    id: crypto.randomUUID(),
    title: body.title,
    description: body.description || "",
    status: "pending",
    createdAt: new Date().toISOString(),
  };

  tasks.push(newTask);

  return Response.json(newTask, { status: 201 });
}
Understand the input validation

A well-built API never trusts incoming data. Without validation, a caller could create a task with no title, leaving your data store full of useless empty entries.

Why validate before creating?

The validation check sits at the very top of the POST function, before any task creation logic runs. This is called "early return" or "guard clause" pattern.

If the request is invalid, you immediately return a 400 response. The function exits and none of the remaining code executes. This keeps your logic flat and easy to follow.

Test with curl

Unlike GET requests, you cannot test POST by typing a URL in your browser. You need a tool that lets you set the HTTP method and send a body. The curl command is built into macOS, Linux, and Windows 10+.

  • Open a new terminal window (keep your dev server running in the existing one).
  • Create a new task by running this command:
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d "{\"title\": \"Learn Next.js APIs\", \"description\": \"Build a task manager\"}"

What does this command do?

  • -X POST tells curl to use the POST method instead of the default GET.
  • -H "Content-Type: application/json" sets a header telling the server the body is JSON.
  • -d sends the JSON string as the request body. The escaped quotes (\") are needed so the shell passes valid JSON to curl.
  • You should see the newly created task returned as JSON with a generated id, status "pending", and a 201 status code.

Now test the validation by sending a request without a title:

  • Test the error response by running this command:
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d "{\"description\": \"No title here\"}"
  • You should see {"error":"Title is required"} returned with a 400 status.

Not seeing the expected output?

Make sure your development server is still running in the other terminal. If it stopped, run npm run dev again from your task-manager-api folder.

If you see a connection error, check that the URL uses port 3000 and that no other process is using that port.

Help me debug my curl POST request to the Next.js API.

Your API now supports both reading and creating tasks. Next up, you will add dynamic routes so callers can fetch, update, and delete individual tasks by their ID.

Build Dynamic Routes for Single Task Operations

Your API can now list all tasks and create new ones. The next challenge is working with individual tasks. Fetching a single task, updating it, or deleting it all require knowing which task the request is about.

Next.js solves this with dynamic route segments. A folder named [id] in your file structure tells Next.js to capture that part of the URL as a parameter. In this step, you will build three new Route Handlers that use this parameter to operate on a single task.

In this step, get ready to:
  • Create a dynamic route file using the [id] folder convention.
  • Build a GET handler that returns a single task by its ID.
  • Add PUT and DELETE handlers for updating and removing tasks.
Create the dynamic route file with a GET handler

When a client requests /api/tasks/1, Next.js needs a route that matches that URL pattern. The [id] folder acts as a wildcard that captures whatever value appears in that URL segment.

  • Create a new folder inside app/api/tasks/ named [id] (including the square brackets).
  • Inside that folder, create a file named route.ts.
  • Add the following code to app/api/tasks/[id]/route.ts:
import { tasks } from "@/app/lib/tasks";

// GET a single task by its ID
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  // Await the params object to extract the dynamic route parameter
  const { id } = await params;
  // Search for a task matching the requested ID
  const task = tasks.find((t) => t.id === id);

  // Return 404 if no task matches
  if (!task) {
    return Response.json(
      { error: "Task not found" },
      { status: 404 }
    );
  }

  return Response.json(task);
}

What does this code do?

The GET function receives two arguments: the request object and a context object containing params. In Next.js 16, params is a Promise, so you must await it before accessing id.

Once you have the id, the handler searches the tasks array for a matching task. If found, it returns the task as JSON. If not, it returns a 404 status with an error message so the client knows the resource does not exist.

Why is params a Promise?

Next.js 16 made params asynchronous to support more advanced rendering optimizations. This means you always destructure it with await before using the values inside. This pattern applies to every dynamic route handler you write.

  • Save app/api/tasks/[id]/route.ts.
  • Visit http://localhost:3000/api/tasks/1 in your browser. You should see the first sample task ("Set up Next.js project") returned as JSON.

Seeing a 404 page instead of JSON?

  • Make sure the folder is named exactly [id] with square brackets. Without them, Next.js treats it as a literal path segment.
  • Check that you saved the file as route.ts (not page.ts or routes.ts).
  • Confirm the import path @/app/lib/tasks matches your file structure exactly.

Why is my dynamic route not matching the URL?

Add the PUT handler for updating tasks

The PUT method lets a client send updated field values for an existing task. Your handler needs to find the task, apply the changes, and return the updated version.

  • Add the following PUT function below your GET function in app/api/tasks/[id]/route.ts:
// PUT updates an existing task's fields
export async function PUT(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();
  // Find the task's position in the array
  const taskIndex = tasks.findIndex((t) => t.id === id);

  // Return 404 if the task doesn't exist
  if (taskIndex === -1) {
    return Response.json(
      { error: "Task not found" },
      { status: 404 }
    );
  }

  // Update only the fields that were provided, keep existing values for the rest
  tasks[taskIndex] = {
    ...tasks[taskIndex],
    title: body.title ?? tasks[taskIndex].title,
    description: body.description ?? tasks[taskIndex].description,
    status: body.status ?? tasks[taskIndex].status,
  };

  return Response.json(tasks[taskIndex]);
}

What does this code do?

The PUT handler reads the request body for updated field values. It uses findIndex instead of find because it needs the array position to update the task in place.

The ?? (nullish coalescing) operator is key here. It means "use the new value if one was provided, otherwise keep the existing value." This lets the client update only the fields they care about without overwriting everything else.

  • Save app/api/tasks/[id]/route.ts.
  • Test the PUT handler by running this command in a new terminal:
curl -X PUT http://localhost:3000/api/tasks/1 -H "Content-Type: application/json" -d "{\"title\": \"Project fully set up\"}"
  • You should see the task returned with the updated title, while the description and status remain unchanged.

Getting a 404 when updating?

  • Make sure the task ID in your URL (1 or 2) matches an ID in your sample tasks array.
  • If you restarted the dev server, the in-memory data resets. The sample task IDs ("1" and "2") are available again after a restart.

Help me debug my PUT route handler returning 404.

Add the DELETE handler

The final handler removes a task from the array entirely. Once deleted, subsequent GET requests for that ID will return 404.

  • Add the following DELETE function below your PUT function in app/api/tasks/[id]/route.ts:
// DELETE removes a task from the array
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  // Find the task's position for removal
  const taskIndex = tasks.findIndex((t) => t.id === id);

  // Return 404 if the task doesn't exist
  if (taskIndex === -1) {
    return Response.json(
      { error: "Task not found" },
      { status: 404 }
    );
  }

  // Remove the task from the array
  tasks.splice(taskIndex, 1);

  return Response.json({ message: "Task deleted successfully" });
}

What does this code do?

The DELETE handler finds the task by index and uses splice to remove it directly from the tasks array. This mutates the shared array so all future requests reflect the deletion.

It returns a 200 status with a success message. If the task does not exist, it returns 404 following the same pattern as the other handlers.

  • Save app/api/tasks/[id]/route.ts.
  • Test the DELETE handler by running:
curl -X DELETE http://localhost:3000/api/tasks/1
  • You should see {"message":"Task deleted successfully"} in the response.
  • Confirm the task is gone by visiting http://localhost:3000/api/tasks in your browser. Only one sample task should remain.

Deleted task still showing up?

  • Make sure you are using splice (which modifies the original array) and not filter (which creates a new array without changing the shared reference).
  • Check that your import at the top of the file is import { tasks } from "@/app/lib/tasks" so you are referencing the same shared array.

Why is my deleted task still appearing in the GET response?

✔️ Awesome, I've got everything!

Great. Double check you have saved app/api/tasks/[id]/route.ts with all three handlers.

ⓧ I'd like to double check the full code

import { tasks } from "@/app/lib/tasks";

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const task = tasks.find((t) => t.id === id);

  if (!task) {
    return Response.json(
      { error: "Task not found" },
      { status: 404 }
    );
  }

  return Response.json(task);
}

export async function PUT(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();
  const taskIndex = tasks.findIndex((t) => t.id === id);

  if (taskIndex === -1) {
    return Response.json(
      { error: "Task not found" },
      { status: 404 }
    );
  }

  tasks[taskIndex] = {
    ...tasks[taskIndex],
    title: body.title ?? tasks[taskIndex].title,
    description: body.description ?? tasks[taskIndex].description,
    status: body.status ?? tasks[taskIndex].status,
  };

  return Response.json(tasks[taskIndex]);
}

export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const taskIndex = tasks.findIndex((t) => t.id === id);

  if (taskIndex === -1) {
    return Response.json(
      { error: "Task not found" },
      { status: 404 }
    );
  }

  tasks.splice(taskIndex, 1);

  return Response.json({ message: "Task deleted successfully" });
}

Your API now handles individual tasks. You can fetch, update, and delete any task by its ID. Next up, you will test the full workflow end to end and verify your error handling.

Test Your Complete API

You have a complete set of Route Handlers covering every CRUD operation your task manager needs.

Now it is time to put the whole API through its paces. You will send real requests to every endpoint, confirm the correct responses come back, and verify that error handling works as expected.

In this step, get ready to:
  • Test the full create, read, update, delete workflow with curl.
  • Verify error handling returns proper status codes.
  • Review the final project structure.
Test the full CRUD flow

A good API test exercises every endpoint in sequence, confirming each operation builds on the last. You will create a task, retrieve it, update it, delete it, and verify it is gone.

  • Retrieve all tasks by running this command in the terminal where you previously ran curl (not the terminal running your dev server):
curl http://localhost:3000/api/tasks

You should see a JSON array containing the two sample tasks (plus any you created in the previous step).

  • Create a new test task by running:
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d "{\"title\": \"Test the API\", \"description\": \"Verify all endpoints work\"}"

You should receive the new task back with a 201 status, including a unique id field.

Copy the task ID

Look for the id value in the JSON response. It is a long string like a1b2c3d4-e5f6-7890-abcd-ef1234567890. Copy it now. You will paste it into the next few commands to target this specific task.

  • Retrieve the single task you just created by running:
curl http://localhost:3000/api/tasks/[[TASK_ID="paste the id from the POST response above"]]

You should see only the one task you just created, returned as a single JSON object.

  • Update the task's title by running:
curl -X PUT http://localhost:3000/api/tasks/[[TASK_ID="paste the id from the POST response above"]] -H "Content-Type: application/json" -d "{\"title\": \"Updated task title\"}"

The response should show the task with its title changed to Updated task title while the description and status remain unchanged.

  • Delete the task by running:
curl -X DELETE http://localhost:3000/api/tasks/[[TASK_ID="paste the id from the POST response above"]]

You should see a JSON response with the message Task deleted successfully.

  • Confirm the deletion by retrieving all tasks one more time:
curl http://localhost:3000/api/tasks

The task you created should no longer appear in the array.

Not seeing the expected responses?

Make sure your dev server is still running in the other terminal. If it stopped, run npm run dev again.

If POST returns an error, double-check that the -H "Content-Type: application/json" header is included. Without it, the server cannot parse the JSON body.

Help me debug my curl commands

Verify error handling

A production-quality API rejects bad requests gracefully. Your route handlers already include validation and 404 checks. Let's confirm they work.

  • Request a task that does not exist by running:
curl http://localhost:3000/api/tasks/nonexistent-id

You should see a 404 response with the message Task not found.

  • Try creating a task without a title by running:
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d "{}"

You should see a 400 response with the message Title is required.

  • Try deleting a task that does not exist by running:
curl -X DELETE http://localhost:3000/api/tasks/nonexistent-id

You should see the same 404 response with Task not found.

Why do status codes matter?

HTTP status codes tell the client what happened without needing to parse the response body. A 400 means the client sent a bad request. A 404 means the resource does not exist.

Frontend apps, mobile apps, and other API consumers rely on these codes to decide what to show the user. Returning the wrong code leads to confusing error messages.

Getting a 500 error instead?

A 500 usually means your code threw an unhandled exception. Check the terminal running your dev server for a stack trace. The most common cause is a typo in the tasks.find() or tasks.findIndex() call.

Help me debug my 500 error

Review the project structure

Your API is working. Take a moment to see how the file structure maps to your URL endpoints.

  • Review the structure of your task-manager-api project:
task-manager-api/
├── app/
│   ├── api/
│   │   └── tasks/
│   │       ├── route.ts          (GET all, POST)
│   │       └── [id]/
│   │           └── route.ts      (GET one, PUT, DELETE)
│   ├── lib/
│   │   └── tasks.ts              (data store)
│   ├── layout.tsx
│   └── page.tsx
├── package.json
├── tsconfig.json
└── next.config.ts

How does folder structure map to URLs?

Each folder inside app/api/ becomes a URL segment. The file app/api/tasks/route.ts handles requests to /api/tasks.

The [id] folder is a dynamic segment. It matches any value in that position, so /api/tasks/abc123 routes to app/api/tasks/[id]/route.ts with id set to abc123.

The lib/ folder sits outside api/ because it is not an endpoint. It is a shared module that your route handlers import.

✔️ Awesome, I've got everything!

Nice work. Your complete REST API is verified and working.

ⓧ I'd like to double check the full code

Here is the full code for each file. Compare yours against these:

export interface Task {
  id: string;
  title: string;
  description: string;
  status: "pending" | "completed";
  createdAt: string;
}

export const tasks: Task[] = [
  {
    id: "1",
    title: "Set up Next.js project",
    description: "Create a new Next.js app with TypeScript",
    status: "completed",
    createdAt: new Date().toISOString(),
  },
  {
    id: "2",
    title: "Build the API",
    description: "Create route handlers for task management",
    status: "pending",
    createdAt: new Date().toISOString(),
  },
];
import { tasks, Task } from "@/app/lib/tasks";

export async function GET() {
  return Response.json(tasks);
}

export async function POST(request: Request) {
  const body = await request.json();

  if (!body.title) {
    return Response.json(
      { error: "Title is required" },
      { status: 400 }
    );
  }

  const newTask: Task = {
    id: crypto.randomUUID(),
    title: body.title,
    description: body.description || "",
    status: "pending",
    createdAt: new Date().toISOString(),
  };

  tasks.push(newTask);

  return Response.json(newTask, { status: 201 });
}
import { tasks } from "@/app/lib/tasks";

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const task = tasks.find((t) => t.id === id);

  if (!task) {
    return Response.json(
      { error: "Task not found" },
      { status: 404 }
    );
  }

  return Response.json(task);
}

export async function PUT(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();
  const taskIndex = tasks.findIndex((t) => t.id === id);

  if (taskIndex === -1) {
    return Response.json(
      { error: "Task not found" },
      { status: 404 }
    );
  }

  tasks[taskIndex] = {
    ...tasks[taskIndex],
    title: body.title ?? tasks[taskIndex].title,
    description: body.description ?? tasks[taskIndex].description,
    status: body.status ?? tasks[taskIndex].status,
  };

  return Response.json(tasks[taskIndex]);
}

export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const taskIndex = tasks.findIndex((t) => t.id === id);

  if (taskIndex === -1) {
    return Response.json(
      { error: "Task not found" },
      { status: 404 }
    );
  }

  tasks.splice(taskIndex, 1);

  return Response.json({ message: "Task deleted successfully" });
}

Your API is fully tested and verified. Next up, you will extend it with a status toggle and filtering in the Secret Mission.

Secret mission

Add Status Toggle and Filtering

Your API handles full CRUD, but real-world APIs need more nuance. Add a dedicated endpoint that toggles a task's status with a single request, plus query parameter filtering so clients can fetch only the tasks they care about.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The task-manager-api project folder on your Desktop.
  • The Next.js development server running at http://localhost:3000.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your API.

  • Your dev server stays running in the terminal. You can keep testing endpoints or adding new ones.
  • If you close the terminal by accident, navigate back to the task-manager-api folder and run npm run dev again.

Pause - I'll come back to this later

Stop the running development server to free up your terminal. Your project files stay on disk so you can pick up where you left off.

  • Switch to the terminal where your dev server is running.
  • Press Ctrl+C to stop the server.
  • Your task-manager-api folder remains on your Desktop. When you are ready to continue, open a terminal, navigate to the folder, and run npm run dev to start the server again.

Delete - I don't want to use this again

Remove all project files and stop the running server. This cannot be undone.

  • Switch to the terminal where your dev server is running.
  • Press Ctrl+C to stop the server.
  • Delete the project folder by running the command for your operating system.

macOS

cd ~/Desktop
rm -rf task-manager-api

Windows

cd ~\Desktop
rmdir /s /q task-manager-api
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see task-manager-api in the output.

Nice Work!

Nice Work!

You just built a complete REST API using nothing but Next.js Route Handlers. That is a fully functional backend service, created without spinning up a separate server or installing any additional frameworks.

Here's what you learned:

  • Built Route Handlers that respond to GET, POST, PUT, and DELETE requests using the standard Web Request/Response APIs.
  • Used dynamic route segments ([id]) to handle individual resource operations and return proper HTTP status codes (200, 201, 400, 404).
  • Organized your API with a shared data module and parsed JSON request bodies to create, update, and delete tasks.
  • Added status toggling with a PATCH endpoint and URL query parameter filtering using NextRequest.

Ready to quiz yourself?