You've learned how to use APIs. Now let's build one. Making an API is WAY less complicated than it sounds. In this lesson you'll build a simple API that takes requests and sends back responses.
For some perspective, I've put 1,200+ hours and $2,000+ in AI tokens into building the four Seedly products (CRM, Sites, Communities and Dispatch). Big apps get built one small piece at a time, and today's piece is tiny.
What We're Building
We'll make a simple API for a task list. Here's what it'll do.
Return a list of tasks
Return a single task by ID
Create a new task
It's a small example, but it teaches the core ideas that show up in every API.
Setting Up
For this example we'll use Next.js API routes. If you're on a different framework, the ideas are the same even if the code looks a lil different.
In Next.js, you make an API endpoint by adding a file in the app/api folder. The file path becomes the URL path.
Example
app/api/tasks/route.js -> /api/tasks
Creating a GET Endpoint
Let's start with the simplest API there is, one that returns a list of tasks.
Example
// app/api/tasks/route.js
// Our fake database (in real apps, you'd use a real database)
const tasks = [
{ id: 1, title: "Learn APIs", completed: false },
{ id: 2, title: "Build a project", completed: false },
{ id: 3, title: "Deploy to production", completed: false }
];
export async function GET() {
return Response.json(tasks);
}
Yep, that's the whole thing. When someone sends a GET request to /api/tasks, they get the list of tasks back as JSON.
Testing Your Endpoint
You can test your API a few different ways.
In your browser.
Just visit http://localhost:3000/api/tasks. You'll see the JSON.
With curl in the terminal.
Example
curl http://localhost:3000/api/tasks
With a tool like Postman.
Make a GET request to your URL and send it.
Getting a Single Task
What if someone only wants one task? Then we need a dynamic route. In Next.js, you put brackets in the folder name.
Example
app/api/tasks/[id]/route.js -> /api/tasks/1, /api/tasks/2, etc.
await params gives us the ID from the URL. In Next.js 15 and newer, params is a Promise, so you have to await it before you can read anything off it (forget the await and id comes back undefined). The ID shows up as a string, so parseInt turns it into a number before we compare.
Step 2. Find the task
We search the tasks array for a matching ID.
Step 3. Handle not found
If nothing matches, we send back a 404 error with a message.
Step 4. Return the task
If we find it, we send the task back as JSON.
Creating a POST Endpoint
Now let's handle making new tasks. POST requests carry data in the body.
Example
// app/api/tasks/route.js
let tasks = [
{ id: 1, title: "Learn APIs", completed: false },
{ id: 2, title: "Build a project", completed: false },
{ id: 3, title: "Deploy to production", completed: false }
];
export async function GET() {
return Response.json(tasks);
}
export async function POST(request) {
// Get the data from the request body
const body = await request.json();
// Validate the data
if (!body.title) {
return Response.json(
{ error: "Title is required" },
{ status: 400 }
);
}
// Create the new task (next ID = highest ID so far + 1)
const newTask = {
id: Math.max(0, ...tasks.map(t => t.id)) + 1,
title: body.title,
completed: false
};
// Add it to our list
tasks.push(newTask);
// Return the new task with 201 Created status
return Response.json(newTask, { status: 201 });
}
Now you can create tasks.
Example
curl -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "My new task"}'
The Complete API
Here's the full task API with GET, POST, single-task lookup and DELETE. There's one catch when you split routes across two files. Both files need to see the SAME list of tasks. So we pull the list out into its own little file and have both routes import it.
Example
// app/api/tasks/data.js
// Our fake database. It lives in memory, so it resets every time the
// server restarts. In real apps, you'd use a real database.
export const tasks = [
{ id: 1, title: "Learn APIs", completed: false },
{ id: 2, title: "Build a project", completed: false }
];
Example
// app/api/tasks/route.js
import { tasks } from "./data.js";
// GET all tasks
export async function GET() {
return Response.json(tasks);
}
// POST new task
export async function POST(request) {
const body = await request.json();
if (!body.title) {
return Response.json(
{ error: "Title is required" },
{ status: 400 }
);
}
const newTask = {
id: Math.max(0, ...tasks.map(t => t.id)) + 1,
title: body.title,
completed: false
};
tasks.push(newTask);
return Response.json(newTask, { status: 201 });
}
Example
// app/api/tasks/[id]/route.js
import { tasks } from "../data.js";
// GET single task
export async function GET(request, { params }) {
const { id } = await params;
const task = tasks.find(t => t.id === parseInt(id));
if (!task) {
return Response.json(
{ error: "Task not found" },
{ status: 404 }
);
}
return Response.json(task);
}
// DELETE task
export async function DELETE(request, { params }) {
const { id } = await params;
const index = tasks.findIndex(t => t.id === parseInt(id));
if (index === -1) {
return Response.json(
{ error: "Task not found" },
{ status: 404 }
);
}
tasks.splice(index, 1);
return new Response(null, { status: 204 });
}
This array is only for local practice. A server restart loses its data, and separate server instances can have different copies. Before deploying a task API, replace tasks with a database. For example, a Prisma version reads with prisma.task.findMany() and writes with prisma.task.create({ data: { title: body.title } }).
Best Practices
Use the Right Status Codes
Action
Success Code
GET (found)
200 OK
POST (created)
201 Created
PUT/PATCH (updated)
200 OK
DELETE (deleted)
204 No Content
Return Useful Errors
A bare "Error." helps nobody. Tell the user what actually went wrong.
Example
// Bad
return Response.json({ error: "Error" }, { status: 400 });
// Good
return Response.json(
{ error: "Title is required and must be at least 3 characters" },
{ status: 400 }
);
Validate Everything
Check every bit of input data. Don't assume any of it is right.
Example
if (!body.title) {
return Response.json({ error: "Title is required" }, { status: 400 });
}
if (body.title.length < 3) {
return Response.json({ error: "Title must be at least 3 characters" }, { status: 400 });
}
if (body.title.length > 100) {
return Response.json({ error: "Title must be less than 100 characters" }, { status: 400 });
}
What You Built
Your API now handles these operations.
Method
Endpoint
What It Does
GET
/api/tasks
Get all tasks
POST
/api/tasks
Create a task
GET
/api/tasks/1
Get task 1
DELETE
/api/tasks/1
Delete task 1
That's a REAL API. It's simple, but it follows the same patterns the pros use.
TL;DR
APIs are just functions that handle HTTP requests
Each HTTP method (GET, POST, PUT, DELETE) gets its own function
Use the right status codes, like 200, 201, 400 and 404
Always validate input data
Send back helpful error messages
In Next.js, the file path becomes the URL path
Almost There
You've covered the core of the API Fundamentals module. Here's everything you've picked up.
What APIs are and why they exist
HTTP methods (GET, POST, PUT, DELETE)
URLs and endpoints
JSON data format
Status codes
Authentication with API keys and tokens
Making API calls with fetch()
Receiving webhooks
Building your own API
Making smart API design decisions
These are core skills for building anything modern. Almost every app you build will use APIs, provide APIs, or both, and you're ready to work with them in real projects now. In the last lesson of this module, you'll check out two real APIs you're likely to run into...