Curriculum1 of 8
+ Module 15

Data & APIs in depth

Design and build the endpoint that powers your own interface.

Phase 3 · Full stack · 8 sections · about 9 minutes

+ 01 / 08

Promise

An endpoint is a contract. Your UI talks to your backend by sending a request: here's the data, here's what I want you to do. The backend sends back a response: here's the result, or here's what went wrong. The contract is explicit, both sides know the shape of the request, the shape of the response, and what can fail.

In this module, you'll stop treating endpoints like magic and start designing them like a real software engineer. You'll build a working API endpoint that handles a real feature (a waitlist form, a note-saving UI, a to-do adder) and learn the patterns that keep endpoints reliable: validation, error handling, and the difference between the three ways Next.js lets you talk to servers.

By the end, you'll have deployed an endpoint that validates input, talks to a database, and reports errors clearly. You'll understand HTTP well enough to debug it. And you'll know which Next.js pattern to reach for in any situation.


+ 02 / 08

Concepts

1. What a request really is

When your browser sends a request, it's not magic. It's structured data: a verb, a path, optional headers, optional body.

+ JavaScript
1POST /api/waitlist HTTP/1.1
2Host: example.com
3Content-Type: application/json
4 
5{
6 "email": "alice@example.com"
7}

Breaking this down:

  • Verb: POST. Tells the server "I want you to create something." (GET = read, POST = create, PUT = update, DELETE = delete)
  • Path: /api/waitlist. The thing you want to do.
  • Headers: metadata about the request. Content-Type: application/json means "the body is JSON."
  • Body: the actual payload, the email address, in this case.

The server responds:

+ JavaScript
1HTTP/1.1 201 Created
2Content-Type: application/json
3 
4{
5 "success": true,
6 "id": "waitlist_abc123"
7}
  • Status code: 201 means "I created it." 200 = success (no create), 400 = bad input, 500 = server error.
  • Response body: what happened. Could be success, could be an error message.

This is everything. A POST with no body is a request. A GET with 20 headers is a request. An image is a request (it returns binary instead of JSON). The language doesn't matter, a request is a request, whether it's coming from a browser, an app, or curl.

+ Terminal
1# This is the same request, from the command line:
2curl -X POST https://example.com/api/waitlist \
3 -H "Content-Type: application/json" \
4 -d '{"email":"alice@example.com"}'

2. Route handlers, the Next.js way

Next.js gives you a file-based router for API endpoints. A file at app/api/waitlist/route.ts handles requests to /api/waitlist.

+ TypeScript
1// app/api/waitlist/route.ts
2export async function POST(request: Request) {
3 // 1. Parse the incoming request
4 const body = await request.json();
5 const email = body.email;
6 
7 // 2. Validate the input
8 if (!email || !email.includes("@")) {
9 return Response.json({ error: "Invalid email" }, { status: 400 });
10 }
11 
12 // 3. Do something (save to database, send email, etc.)
13 // For now, just pretend:
14 const id = Math.random().toString(36).slice(2, 9);
15 
16 // 4. Respond with success
17 return Response.json({ success: true, id }, { status: 201 });
18}

Every route handler is an async function named after the HTTP verb it handles. POST, GET, PUT, DELETE. The function receives the Request, does work, and returns a Response.

Multiple verbs on the same route:

+ TypeScript
1// Handles both GET /api/notes and POST /api/notes
2export async function GET(request: Request) {
3 return Response.json({ notes: [] });
4}
5 
6export async function POST(request: Request) {
7 const body = await request.json();
8 // save it
9 return Response.json({ id: "note_123" }, { status: 201 });
10}

The status code matters. Use:

  • 200 (default): success, no creation
  • 201: success, created something
  • 400: client error (bad input)
  • 401: unauthorized (not logged in)
  • 404: not found
  • 500: server error (your code broke)

3. Server actions, the React way

Server actions are a shortcut. Instead of writing a route handler, you write a function marked 'use server' that lives inside your component or in a shared file. When you call it from the browser, Next.js automatically sends it to the server and runs it there.

+ TypeScript
1// actions/saveNote.ts
2"use server";
3 
4export async function saveNote(title: string, body: string) {
5 // This code runs on the server only.
6 // Validate the input
7 if (!title || title.trim().length === 0) {
8 throw new Error("Title is required");
9 }
10 
11 // Save to database (pretend)
12 const id = Math.random().toString(36).slice(2, 9);
13 return { id };
14}

Call it from a client component:

+ TypeScript
1// components/NoteForm.tsx
2'use client';
3 
4import { saveNote } from '@/actions/saveNote';
5 
6export default function NoteForm() {
7 async function handleSubmit(formData: FormData) {
8 const title = formData.get('title') as string;
9 const body = formData.get('body') as string;
10 
11 try {
12 const result = await saveNote(title, body);
13 console.log('Saved:', result.id);
14 } catch (error) {
15 console.error('Failed:', error.message);
16 }
17 }
18 
19 return (
20 <form action={handleSubmit}>
21 <input name="title" placeholder="Title" required />
22 <textarea name="body" placeholder="Body" required />
23 <button type="submit">Save</button>
24 </form>
25 );
26}

Server actions are simpler than route handlers for straightforward cases (form submission, single operation). But they're less flexible, you can't set custom status codes, you can't handle different HTTP verbs cleanly, and testing them is harder. Use route handlers when you need control; use server actions when you want simplicity.

4. Validation, the most important part

Never trust user input. Ever. Your API is a public door. Anyone can send anything.

+ TypeScript
1export async function POST(request: Request) {
2 const body = await request.json();
3 
4 // Attacker sends: { email: null, count: "DROP TABLE users;", user_id: 12345 }
5 // Without validation, you're in trouble.
6 
7 // Validate each field:
8 if (typeof body.email !== "string" || !body.email.includes("@")) {
9 return Response.json(
10 { error: "email must be a valid email address" },
11 { status: 400 },
12 );
13 }
14 
15 if (typeof body.count !== "number" || body.count < 0) {
16 return Response.json(
17 { error: "count must be a positive number" },
18 { status: 400 },
19 );
20 }
21 
22 // Now you can use body.email and body.count safely.
23}

For larger schemas, use a validation library like Zod:

+ TypeScript
1import { z } from "zod";
2 
3const WaitlistSchema = z.object({
4 email: z.string().email("Invalid email address"),
5 name: z.string().min(1, "Name is required").max(100),
6 referral: z.string().optional(),
7});
8 
9export async function POST(request: Request) {
10 const body = await request.json();
11 
12 // If validation fails, Zod throws an error with details
13 const parsed = WaitlistSchema.parse(body);
14 
15 // parsed.email, parsed.name, parsed.referral are now type-safe
16 // and you know they meet the schema.
17}

Wrap it in a try-catch to catch validation errors:

+ TypeScript
1export async function POST(request: Request) {
2 try {
3 const body = await request.json();
4 const parsed = WaitlistSchema.parse(body);
5 
6 // Save to database
7 const id = await db.waitlist.create(parsed);
8 
9 return Response.json({ id }, { status: 201 });
10 } catch (error) {
11 if (error instanceof z.ZodError) {
12 return Response.json(
13 { error: "Validation failed", details: error.issues },
14 { status: 400 },
15 );
16 }
17 
18 console.error("Unexpected error:", error);
19 return Response.json({ error: "Something went wrong" }, { status: 500 });
20 }
21}

The rule: validate early, fail fast, report clearly.


+ 03 / 08

Build: A working endpoint

You're going to build a real endpoint that saves email addresses to a waitlist. The UI will be a form; the backend will validate the email and respond with success or an error.

Step 1: Create the route handler

Create app/api/waitlist/route.ts:

+ TypeScript
1import { z } from "zod";
2 
3const EmailSchema = z.object({
4 email: z.string().email("Invalid email address"),
5});
6 
7export async function POST(request: Request) {
8 try {
9 const body = await request.json();
10 const { email } = EmailSchema.parse(body);
11 
12 // For now, just pretend we saved it. In Databases & ORM you'll use a real database.
13 // Simulate a random ID like a database would generate
14 const id = Math.random().toString(36).slice(2, 9);
15 
16 // Simulate a small delay (like a database write would take)
17 await new Promise((resolve) => setTimeout(resolve, 200));
18 
19 return Response.json(
20 {
21 success: true,
22 id,
23 message: `Added ${email} to the waitlist`,
24 },
25 { status: 201 },
26 );
27 } catch (error) {
28 if (error instanceof z.ZodError) {
29 return Response.json(
30 { error: "Validation failed", details: error.issues },
31 { status: 400 },
32 );
33 }
34 
35 if (error instanceof SyntaxError) {
36 return Response.json({ error: "Invalid JSON" }, { status: 400 });
37 }
38 
39 console.error("Unexpected error:", error);
40 return Response.json({ error: "Something went wrong" }, { status: 500 });
41 }
42}
43 
44export async function GET() {
45 return Response.json({
46 message: "POST your email to join the waitlist",
47 });
48}

Step 2: Create the form component

Create components/WaitlistForm.tsx:

+ TypeScript
1'use client';
2 
3import { useState } from 'react';
4 
5export default function WaitlistForm() {
6 const [email, setEmail] = useState('');
7 const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
8 const [message, setMessage] = useState('');
9 
10 async function handleSubmit(e: React.FormEvent) {
11 e.preventDefault();
12 setStatus('loading');
13 setMessage('');
14 
15 try {
16 const response = await fetch('/api/waitlist', {
17 method: 'POST',
18 headers: { 'Content-Type': 'application/json' },
19 body: JSON.stringify({ email }),
20 });
21 
22 const data = await response.json();
23 
24 if (!response.ok) {
25 setStatus('error');
26 setMessage(data.error || 'Something went wrong');
27 return;
28 }
29 
30 setStatus('success');
31 setMessage(data.message);
32 setEmail('');
33 } catch (error) {
34 setStatus('error');
35 setMessage('Network error. Try again.');
36 }
37 }
38 
39 return (
40 <form onSubmit={handleSubmit} className="space-y-4">
41 <div>
42 <input
43 type="email"
44 value={email}
45 onChange={(e) => setEmail(e.target.value)}
46 placeholder="your@email.com"
47 required
48 className="w-full px-4 py-2 border rounded"
49 disabled={status === 'loading'}
50 />
51 </div>
52 
53 <button
54 type="submit"
55 disabled={status === 'loading'}
56 className="w-full px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
57 >
58 {status === 'loading' ? 'Adding...' : 'Join Waitlist'}
59 </button>
60 
61 {message && (
62 <p className={status === 'success' ? 'text-green-600' : 'text-red-600'}>
63 {message}
64 </p>
65 )}
66 </form>
67 );
68}

Step 3: Add the form to a page

In any page (e.g., app/page.tsx), import and use it:

+ TypeScript
1import WaitlistForm from '@/components/WaitlistForm';
2 
3export default function Home() {
4 return (
5 <div className="max-w-md mx-auto mt-20">
6 <h1 className="text-3xl font-bold mb-8">Join our waitlist</h1>
7 <WaitlistForm />
8 </div>
9 );
10}

Step 4: Test it

Run npm run dev and open http://localhost:3000. Try:

  1. 1Valid email: alice@example.com → should show "Added alice@example.com to the waitlist"
  2. 2Invalid email: notanemail → should show "Validation failed"
  3. 3Empty field: → HTML validation prevents submit
  4. 4Network error: Open DevTools, go to Network tab, throttle to offline, try submitting → should show "Network error"

+ 04 / 08

Patterns & Examples

Error handling

Always distinguish between client errors (400) and server errors (500):

+ TypeScript
1export async function POST(request: Request) {
2 try {
3 const body = await request.json();
4 // ... validation ...
5 
6 // Simulating a database call
7 const result = await saveToDatabase(body);
8 return Response.json(result, { status: 201 });
9 } catch (error) {
10 // Client error: bad input
11 if (error instanceof ValidationError) {
12 return Response.json({ error: error.message }, { status: 400 });
13 }
14 
15 // Server error: something broke
16 console.error("Database error:", error);
17 return Response.json(
18 { error: "Failed to save. Try again later." },
19 { status: 500 },
20 );
21 }
22}

Query parameters

For GET requests, parameters come from the URL:

+ TypeScript
1export async function GET(
2 request: Request,
3 { params }: { params: { id: string } },
4) {
5 // /api/notes/123 → params.id = '123'
6 
7 // Query string: /api/notes?limit=10&sort=date
8 const { searchParams } = new URL(request.url);
9 const limit = searchParams.get("limit") ?? "20";
10 const sort = searchParams.get("sort") ?? "date";
11 
12 return Response.json({
13 limit,
14 sort,
15 });
16}

Dynamic routes

For a route like /api/notes/[id]/route.ts:

+ TypeScript
1export async function GET(
2 request: Request,
3 { params }: { params: { id: string } },
4) {
5 const { id } = params;
6 
7 // Validate the ID (is it a number? is it the right format?)
8 if (!/^\d+$/.test(id)) {
9 return Response.json({ error: "Invalid note ID" }, { status: 400 });
10 }
11 
12 // Fetch from database
13 const note = { id, title: "Example note" };
14 
15 return Response.json(note);
16}
17 
18export async function PUT(
19 request: Request,
20 { params }: { params: { id: string } },
21) {
22 const { id } = params;
23 const body = await request.json();
24 
25 // Update the note
26 return Response.json({ success: true });
27}
28 
29export async function DELETE(
30 request: Request,
31 { params }: { params: { id: string } },
32) {
33 const { id } = params;
34 
35 // Delete the note
36 return Response.json({ success: true });
37}

Middleware: authentication

Before handling a request, check if the user is logged in:

+ TypeScript
1// app/api/notes/route.ts
2import { getSession } from "@/lib/auth"; // pretend function
3 
4export async function POST(request: Request) {
5 // Check auth first
6 const session = await getSession(request);
7 if (!session) {
8 return Response.json({ error: "Not authenticated" }, { status: 401 });
9 }
10 
11 // Now you know the user is logged in
12 const body = await request.json();
13 // ... rest of handler
14}

+ 05 / 08

Checkpoint

Build a working endpoint for one of these features:

Option A: Waitlist (simplest)

  • Endpoint: POST /api/waitlist
  • Accepts: { email: string }
  • Validates: email is a valid email address
  • Returns: { success: true, id: string } on success, or { error: string } on failure
  • Status codes: 201 on success, 400 on validation error, 500 on server error
  • Form: text input for email, submit button, shows success message or error

Test:

  • Valid email → shows success
  • Invalid email → shows validation error
  • Clicking while loading → button is disabled

Option B: Note saver (more realistic)

  • Endpoint: POST /api/notes
  • Accepts: { title: string, body: string, tags?: string[] }
  • Validates: title is 1–100 characters, body is 1–10000 characters, tags are strings
  • Returns: { success: true, id: string } with the note ID
  • Endpoint: GET /api/notes returns list of saved notes
  • Form: text input for title, textarea for body, tags field, submit button

Test:

  • Empty title → error
  • Long title (>100 chars) → error
  • Valid note → saved and displayed in list
  • Multiple saves → each gets unique ID

Option C: To-do adder (with delete)

  • Endpoint: POST /api/todos
  • Accepts: { text: string, priority: 'low' | 'medium' | 'high' }
  • Validates: text is 1–200 characters, priority is one of the three values
  • Returns: the created to-do with ID
  • Endpoint: DELETE /api/todos/[id]
  • Form: text input, priority dropdown, add button, shows list of to-dos with delete button on each

Test:

  • Empty text → error
  • Invalid priority → error
  • Add multiple to-dos → all appear in list
  • Delete a to-do → removed from list

Requirements (all options)

+ Checklist0 / 7

Testing checklist

+ Terminal
1# Test the endpoint manually with curl:
2 
3# Valid request:
4curl -X POST http://localhost:3000/api/waitlist \
5 -H "Content-Type: application/json" \
6 -d '{"email":"alice@example.com"}'
7 
8# Invalid request:
9curl -X POST http://localhost:3000/api/waitlist \
10 -H "Content-Type: application/json" \
11 -d '{"email":"notanemail"}'
12 
13# Malformed JSON:
14curl -X POST http://localhost:3000/api/waitlist \
15 -H "Content-Type: application/json" \
16 -d 'this is not json'

Each should return the correct status code and error message.


+ 06 / 08

Why HTTP outlives the framework

The protocol is not the framework

An HTTP endpoint is a language-agnostic contract. You can write an endpoint in Node, Python, Go, Rust, the protocol is the same. A POST request is a POST request. A 400 status code means the same thing everywhere.

Next.js provides the framework (route handlers, Response API, server actions), but you need to understand the language (HTTP) first. If you learn Next.js without learning HTTP, you'll hit a wall the moment you need to integrate with a third-party API, debug a network issue, or switch frameworks.

In this module, the framework is Next.js. But the concept is HTTP. Write the POST handler so well that you could rewrite it in Flask (Python) and it would behave identically. That's the difference between learning a tool and learning to think like an engineer.

Question: Can you rewrite your waitlist endpoint in a different language without changing the contract (same inputs, same outputs, same status codes)? Try it in your mind. That's how you know you've learned the pattern, not just the syntax.

What the platform hands you

Next.js gives you Request and Response objects from the web standard. They come from the platform (the browser API, the Node.js runtime). You're not writing framework-specific code, you're writing platform-standard code.

This has a huge upside: your endpoint code is portable. The Response.json() method works the same way in Next.js, in a Worker, in a Node.js HTTP server. But it also means you need to understand what the platform gives you, not just how Next.js wraps it.

Study the Request object. It has .json(), .text(), .blob(), .formData(). Each parses the body differently. It has a .url property (a string), which you can wrap in new URL() to extract query parameters. It has headers. Understand these, and you'll be able to debug a request-handling problem in seconds instead of an hour.

Same with Response. You could construct it manually: new Response(JSON.stringify(data), { status: 201, headers: ... }). Or use the shortcut Response.json(). Both are valid. The shortcut is usually better, but know the underlying platform.

Question: What happens if you forget to await request.json()? Try it. The request body is a stream, you have to explicitly consume it. That's the platform. It's not a Next.js quirk. Understanding that distinction will save you.


+ 07 / 08

Key takeaways

  1. 1An HTTP request is a contract. Verb, path, headers, body. Verb tells you what the client wants (GET = read, POST = create, etc.). Status code tells the client what happened (201 = created, 400 = bad input, 500 = error).
  1. 1Validate every input. Use Zod or similar. Never assume the shape of the data.
  1. 1Choose the right tool. Route handlers for flexibility and control. Server actions for simplicity on one-off forms.
  1. 1Fail fast and fail clearly. Validate early, throw early, catch errors and respond with a meaningful message.
  1. 1Understand the platform. Request and Response are web standards. Learn them, and you'll be able to work in any framework.
  1. 1Test it manually. Open curl. Hit your endpoint. See what it returns. That's how you'll debug 90% of your API problems.

+ 08 / 08

Further reading

+ Up nextDatabases & ORMPreviouslyDesigning AI-native interaction patterns