A Next.js app with a working contact form that uses a Server Action to validate and save data — no API route needed, no useState, just a form that works.
API Routes with Route Handlers
API routes in the App Router are called Route Handlers. Create a route.ts file in any directory under app/ to define HTTP methods as exported functions.
import { NextResponse } from 'next/server'
const posts = [
{ id: 1, title: 'Hello World' },
{ id: 2, title: 'Next.js is Great' },
]
// GET /api/posts
export async function GET() {
return NextResponse.json(posts)
}
// POST /api/posts
export async function POST(request: Request) {
const body = await request.json()
if (!body.title) {
return NextResponse.json(
{ error: 'Title required' },
{ status: 400 }
)
}
const newPost = { id: posts.length + 1, ...body }
posts.push(newPost)
return NextResponse.json(newPost, { status: 201 })
}Server Actions — Forms Without APIs
Server Actions let you define server-side functions that forms call directly — no API route, no fetch() call, no useState. The form submits and the action runs on the server. Works even with JavaScript disabled.
'use server' // at the top of an actions file
import { revalidatePath } from 'next/cache'
async function submitContact(formData: FormData) {
'use server' // or inline like this
const name = formData.get('name') as string
const email = formData.get('email') as string
const message = formData.get('message') as string
// Validate
if (!name || !email || !message) {
throw new Error('All fields required')
}
// Save to DB, send email, etc.
console.log({ name, email, message })
// Revalidate the page cache
revalidatePath('/contact')
}
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
)
}Data Fetching Patterns
Next.js has three data fetching patterns. Understanding when to use each one is the key to building fast apps.
// ISR: revalidate every 60 seconds
export const revalidate = 60
// Or disable caching entirely (SSR)
export const dynamic = 'force-dynamic'
// Or make fully static (SSG)
export const dynamic = 'force-static'
async function getBlogPosts() {
const res = await fetch('/api/posts', {
next: { revalidate: 60 } // ISR on per-fetch level
})
return res.json()
}Middleware and Protected Routes
Middleware runs before every request and can redirect, rewrite, or add headers. Use it to protect routes without loading the full page first.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')
// Protect /dashboard routes
if (request.nextUrl.pathname.startsWith('/dashboard') && !token) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*']
}What You Learned Today
- Built REST API route handlers for GET and POST with proper status codes
- Used Server Actions to handle form submission without any client-side fetch or state
- Understood the three data fetching modes: SSG, SSR, and ISR — and when to use each
- Added route protection via Next.js Middleware that redirects unauthenticated users
Go Further on Your Own
- Add optimistic updates to a Server Action using useOptimistic from React
- Create an intercepting route that shows a modal instead of a new page (Next.js parallel routes)
- Add rate limiting to your API route using the Upstash Redis rate limiter
Nice work. Keep going.
Day 3 is ready when you are.
Continue to Day 3Want live instruction and hands-on projects? Join the AI bootcamp — 3 days, 5 cities.