Complete Guide to Next.js API Routes: From Route Handlers to Error Handling Best Practices

Last Friday afternoon, the product manager came over and asked: “Can you add a user registration endpoint?” I opened the project’s pages/api folder, ready to follow the usual pattern, but found it empty. Then I remembered—this was an App Router project, and the API writing approach had completely changed.
Opening the Next.js docs, I saw the term “Route Handlers” and my heart sank—another new concept. I spent the whole afternoon digging through documentation and examples before finally understanding what route.ts was all about and why I couldn’t use the familiar req and res anymore.
If you’re also confused about Next.js backend API approaches, this article will help clarify things. I’ll use comparisons to explain exactly what changed between Pages Router and App Router APIs, then teach you how to handle requests, design responses, and gracefully handle errors through practical examples. Don’t worry—after reading this, you’ll confidently write backend APIs with Next.js.
API Routes Fundamentals: Core Differences Between Approaches
The Pages Router Era
Before Next.js 13, we wrote all our endpoints in the pages/api folder. The approach was Express-like, using Node.js req and res objects:
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
res.status(200).json({ message: 'Hello from Pages Router!' })
}The benefit was quick onboarding—anyone with Node.js or Express experience could write these without learning much. However, it had downsides: it relied on Node.js-specific APIs, causing issues when deploying to edge environments (Edge Runtime).
The App Router Era with Route Handlers
After Next.js 13 introduced App Router, the API approach completely changed. Now you create route.ts files in the app directory, using Web standard Request and Response APIs:
// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello from Route Handlers!' })
}At first, I found this unfamiliar too. Why can’t we use the old req and res? Later I understood the reasons:
- Embracing Web Standards: Using the browser’s native
RequestandResponseAPIs makes code more universal and aligns with modern web development trends - Better Type Safety: TypeScript support is more complete, no need for extra type definitions
- Edge Runtime Support: Can deploy to Vercel Edge, Cloudflare Workers and other edge environments for faster responses
Core Comparison
| Feature | Pages Router | App Router |
|---|---|---|
| File Location | pages/api/* | app/*/route.ts |
| API Design | Node.js req/res | Web Standard Request/Response |
| HTTP Methods | Single default export, manually check req.method | Independent export per method (GET, POST, etc.) |
| Caching Behavior | No caching | GET requests cached by default |
Honestly, I didn’t understand why the change was necessary at first. After using it for a while, I realized the new approach is indeed clearer—especially when handling different HTTP methods, no more writing lots of if (req.method === 'GET') checks.
Route Handlers in Practice: Creating and Handling Different HTTP Requests
Supported HTTP Methods
Route Handlers support 7 HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Each method corresponds to a named export function. I really like this design—the code structure immediately shows which operations an endpoint supports.
Here’s a complete user management endpoint example:
// app/api/users/route.ts
// Get user list
export async function GET(request: Request) {
// Get query parameters from URL
const { searchParams } = new URL(request.url)
const page = searchParams.get('page') || '1'
return Response.json({
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
],
page: parseInt(page)
})
}
// Create new user
export async function POST(request: Request) {
// Parse JSON body
const body = await request.json()
return Response.json({
id: 3,
name: body.name
}, { status: 201 })
}Handling Request Data
Next.js Route Handlers provide several ways to get request data. I got confused at first too, but here’s what I sorted out:
- URL Parameters: Use
request.urlwithURLobject
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q')- Request Body (JSON): Use
await request.json()
const body = await request.json()
console.log(body.email) // Access email field- Request Body (FormData): Use
await request.formData()
const formData = await request.formData()
const file = formData.get('avatar')- Headers and Cookies: Import from
next/headers
import { headers, cookies } from 'next/headers'
export async function GET() {
const headersList = headers()
const cookieStore = cookies()
const token = headersList.get('authorization')
const userId = cookieStore.get('user_id')
// ...
}- Dynamic Route Parameters: Access via second function parameter
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const userId = params.id
return Response.json({ userId })
}One gotcha I hit: params is async in Next.js 15+, requiring await params.id, though in most cases you can use it directly and TypeScript will warn you.
Constructing Responses
Returning JSON is the most common scenario, just use Response.json():
export async function GET() {
return Response.json({
success: true,
data: { message: 'Operation successful' }
})
}Setting custom status codes and headers is simple:
export async function POST(request: Request) {
const body = await request.json()
// Parameter validation failed, return 400
if (!body.email) {
return Response.json(
{ error: 'Email cannot be empty' },
{ status: 400 }
)
}
// Created successfully, return 201 with headers
return Response.json(
{ id: 123, email: body.email },
{
status: 201,
headers: {
'X-Request-Id': 'abc-123',
'Cache-Control': 'no-cache'
}
}
)
}Real Scenario: User Registration Endpoint
Let’s tie the concepts together with a complete user registration endpoint:
// app/api/auth/register/route.ts
import { headers } from 'next/headers'
export async function POST(request: Request) {
// Get request headers
const headersList = headers()
const contentType = headersList.get('content-type')
// Check Content-Type
if (!contentType?.includes('application/json')) {
return Response.json(
{ error: 'Please submit data in JSON format' },
{ status: 400 }
)
}
// Parse request body
const body = await request.json()
const { username, email, password } = body
// Basic validation
if (!username || !email || !password) {
return Response.json(
{ error: 'Username, email and password cannot be empty' },
{ status: 400 }
)
}
// Should call database to save user here
// const user = await db.user.create({ username, email, password })
// Return success response
return Response.json({
success: true,
data: {
id: 1,
username,
email
}
}, { status: 201 })
}This example covers header checking, JSON parsing, parameter validation, error handling and success responses—basically the pattern for most endpoints.
Error Handling Best Practices: Making APIs More Stable and Reliable
Proper Try-Catch Usage
When I first started writing APIs, I’d wrap everything in one big try-catch:
// ❌ Not recommended: one big try-catch for everything
export async function POST(request: Request) {
try {
const body = await request.json()
// Lots of business logic...
return Response.json({ success: true })
} catch (error) {
return Response.json({ error: 'Operation failed' }, { status: 500 })
}
}This has a problem—all errors return 500, the frontend gets no specific information, making debugging painful. Later I changed to handling different operations separately:
// ✅ Recommended: distinguish different error types
export async function POST(request: Request) {
let body
// Handle JSON parsing errors separately
try {
body = await request.json()
} catch (error) {
return Response.json(
{ error: 'Invalid request format, please check JSON' },
{ status: 400 }
)
}
// Return validation errors directly, no try-catch needed
if (!body.email || !body.password) {
return Response.json(
{ error: 'Email and password cannot be empty' },
{ status: 400 }
)
}
// Handle database operation errors separately
try {
const user = await db.user.create(body)
return Response.json({ success: true, data: user })
} catch (error) {
// Check if duplicate email
if (error.code === 'P2002') {
return Response.json(
{ error: 'This email is already registered' },
{ status: 409 }
)
}
// Other database errors
console.error('Database error:', error)
return Response.json(
{ error: 'Server error, please try again later' },
{ status: 500 }
)
}
}After this change, error messages became much clearer, and the frontend could handle different status codes appropriately.
Structured Error Responses
I’ve worked on many projects and found error response formats all over the place—sometimes { error: '...' }, sometimes { message: '...' }, sometimes { msg: '...' }. Frontend integration becomes very painful.
Later I established a standard format:
// Unified error response format
interface ErrorResponse {
success: false
error: string // User-friendly error message
code?: string // Error code for frontend i18n
details?: any // Detailed error info (dev environment)
requestId?: string // Request tracking ID
}
// Unified success response format
interface SuccessResponse<T> {
success: true
data: T
requestId?: string
}In practice, you can write helper functions:
// lib/api-response.ts
import { nanoid } from 'nanoid'
export function successResponse<T>(data: T, status: number = 200) {
return Response.json({
success: true,
data,
requestId: nanoid()
}, { status })
}
export function errorResponse(
error: string,
status: number = 500,
code?: string,
details?: any
) {
const isDev = process.env.NODE_ENV === 'development'
return Response.json({
success: false,
error,
code,
details: isDev ? details : undefined, // Don't return details in production
requestId: nanoid()
}, { status })
}This makes writing endpoints much cleaner:
// app/api/users/[id]/route.ts
import { successResponse, errorResponse } from '@/lib/api-response'
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const userId = params.id
try {
const user = await db.user.findUnique({ where: { id: userId } })
if (!user) {
return errorResponse('User not found', 404, 'USER_NOT_FOUND')
}
return successResponse(user)
} catch (error) {
return errorResponse(
'Failed to get user information',
500,
'INTERNAL_ERROR',
error
)
}
}Expected vs Unexpected Errors
After writing APIs for a while, I realized errors fall into two categories:
- Expected errors: Invalid parameters, resource not found, insufficient permissions—these are normal business logic, should return 4xx status codes
- Unexpected errors: Database connection failure, third-party service down, code bugs—these are system-level errors, should return 5xx status codes
Handling these two types differently:
export async function POST(request: Request) {
const body = await request.json()
// Expected error: return directly, no need to log
if (!body.email?.includes('@')) {
return errorResponse('Invalid email format', 400, 'INVALID_EMAIL')
}
try {
// Call external API
const response = await fetch('https://api.example.com/verify', {
method: 'POST',
body: JSON.stringify({ email: body.email })
})
if (!response.ok) {
// Third-party API error, expected error
return errorResponse('Email verification failed', 400, 'VERIFICATION_FAILED')
}
return successResponse({ verified: true })
} catch (error) {
// Network errors, timeouts, etc., unexpected errors
console.error('Unexpected error:', error) // Log it
// Can integrate monitoring like Sentry
// Sentry.captureException(error)
return errorResponse(
'Service temporarily unavailable, please try again later',
503,
'SERVICE_UNAVAILABLE'
)
}
}Avoiding Sensitive Information Leaks
I hit this gotcha in early projects—directly returning database error messages to the frontend, exposing the database schema. The correct approach:
try {
const user = await db.user.create(body)
return successResponse(user)
} catch (error) {
// ❌ Dangerous: return raw error
// return errorResponse(error.message, 500)
// ✅ Safe: return generic error, details only in logs
console.error('Database error:', {
error,
userId: request.headers.get('user-id'),
timestamp: new Date().toISOString()
})
return errorResponse(
'Failed to create user, please try again later',
500,
'CREATE_USER_FAILED'
)
}Distinguish between production and development environments:
const isDev = process.env.NODE_ENV === 'development'
return Response.json({
success: false,
error: 'Operation failed',
// Only return detailed errors in development
stack: isDev ? error.stack : undefined,
details: isDev ? error : undefined
}, { status: 500 })Response Format Design: Key to Frontend-Backend Collaboration
RESTful API Design Principles
When designing APIs, I follow RESTful principles—not saying you must strictly adhere, but these rules do make APIs easier to understand.
Core points:
Use HTTP methods to express operations:
- GET: Retrieve resource
- POST: Create resource
- PUT/PATCH: Update resource
- DELETE: Delete resource
Use URLs to express resources:
/api/users- User list/api/users/123- User with ID 123/api/users/123/posts- That user’s posts
Use status codes to express results:
- 200: Success
- 201: Created successfully
- 400: Client parameter error
- 401: Not logged in
- 403: No permission
- 404: Resource not found
- 500: Server error
For example, a user management API could be designed like this:
// app/api/users/route.ts
export async function GET(request: Request) {
// GET /api/users?page=1&limit=20
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit
})
return Response.json({ success: true, data: users })
}
export async function POST(request: Request) {
// POST /api/users
const body = await request.json()
const user = await db.user.create({ data: body })
return Response.json(
{ success: true, data: user },
{ status: 201 } // Note using 201 here
)
}
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
// GET /api/users/123
const user = await db.user.findUnique({ where: { id: params.id } })
if (!user) {
return Response.json(
{ success: false, error: 'User not found' },
{ status: 404 }
)
}
return Response.json({ success: true, data: user })
}
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
// PATCH /api/users/123
const body = await request.json()
const user = await db.user.update({
where: { id: params.id },
data: body
})
return Response.json({ success: true, data: user })
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
// DELETE /api/users/123
await db.user.delete({ where: { id: params.id } })
return Response.json({ success: true, data: null })
}Unified Response Format
I mentioned unified response format when discussing error handling. Let me expand on that. The format I use now:
// Base response types
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string; code?: string }
// Paginated response
interface PaginatedResponse<T> {
success: true
data: T[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
// List response example
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const [users, total] = await Promise.all([
db.user.findMany({ skip: (page - 1) * limit, take: limit }),
db.user.count()
])
return Response.json({
success: true,
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
})
}TypeScript Type Definitions
Since both frontend and backend use TypeScript, share type definitions too. I usually create a types folder in the project:
// types/api.ts
export interface User {
id: string
username: string
email: string
createdAt: string
}
export interface CreateUserRequest {
username: string
email: string
password: string
}
export interface CreateUserResponse {
success: true
data: User
}
// types/api-client.ts
import type { CreateUserRequest, CreateUserResponse } from './api'
export async function createUser(data: CreateUserRequest) {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
const result: CreateUserResponse = await response.json()
if (!result.success) {
throw new Error(result.error)
}
return result.data
}This way the frontend gets complete type hints when calling APIs—much more comfortable to write code.
Common Issues and Solutions
GET Request Default Caching Problem
This gotcha hit me the hardest. I once wrote a user info query endpoint, everything worked fine locally, but after deployment I found that after modifying user info, the frontend still got old data. After extensive debugging I discovered Next.js GET requests are cached by default.
Why? The Next.js team’s thinking was—many GET requests return static data, caching improves performance. But in actual development, most GET requests are dynamic, needing real-time data.
The solution is simple, add one line of config in the route.ts file:
// app/api/users/route.ts
export const dynamic = 'force-dynamic' // Disable caching
export async function GET() {
const users = await db.user.findMany()
return Response.json({ success: true, data: users })
}Other methods:
// Method 2: Set revalidate to 0
export const revalidate = 0
// Method 3: Set Cache-Control in response headers
export async function GET() {
const users = await db.user.findMany()
return Response.json(
{ success: true, data: users },
{
headers: {
'Cache-Control': 'no-store, max-age=0'
}
}
)
}When do you need caching? If your endpoint returns essentially static data, like country lists or category lists, you can leverage caching:
// app/api/countries/route.ts
export const revalidate = 3600 // Cache for 1 hour
export async function GET() {
const countries = await db.country.findMany()
return Response.json({ success: true, data: countries })
}API 404 After Deployment Problem
Local development works fine, but after deploying to Vercel or Netlify, all endpoints return 404. I’ve encountered this several times, reasons are usually:
- Wrong filename: Must be
route.tsorroute.js, can’t beapi.tsor something else - Wrong location:
route.tscan’t be in the same folder aspage.tsx - Not committed to git: Check
.gitignore, ensure route files are committed
Checklist:
# ✅ Correct structure
app/
api/
users/
route.ts # Correct: GET /api/users
users/
[id]/
route.ts # Correct: GET /api/users/123
# ❌ Wrong structure
app/
api/
users.ts # Wrong: should be route.ts
users/
page.tsx
route.ts # Wrong: can't be same level as page.tsxAn easily overlooked point—ensure your next.config.js doesn’t exclude the app directory:
// next.config.js
module.exports = {
// Don't have this config, otherwise app directory will be ignored
// pageExtensions: ['page.tsx', 'page.ts'],
}Redirect in Try-Catch Problem
Next.js’s redirect() function throws a special error to implement redirection. If you put it in try-catch, the redirect won’t work:
import { redirect } from 'next/navigation'
// ❌ Wrong approach
export async function GET() {
try {
const isLoggedIn = await checkAuth()
if (!isLoggedIn) {
redirect('/login') // This redirect will be caught
}
return Response.json({ success: true })
} catch (error) {
return Response.json({ error: 'Operation failed' }, { status: 500 })
}
}
// ✅ Correct approach
export async function GET() {
const isLoggedIn = await checkAuth()
if (!isLoggedIn) {
redirect('/login') // Call outside try-catch
}
try {
const data = await fetchData()
return Response.json({ success: true, data })
} catch (error) {
return Response.json({ error: 'Operation failed' }, { status: 500 })
}
}Honestly though, using redirect() in Route Handlers is rare—most of the time just return 401 status and let the frontend handle redirection.
When You Don’t Need Route Handlers
When I first encountered App Router, I thought all data fetching required writing API endpoints. Later I discovered Server Components can call backend logic directly, no need to go through HTTP requests.
For example:
// ❌ Not recommended: write API first, then call from component
// app/api/posts/route.ts
export async function GET() {
const posts = await db.post.findMany()
return Response.json({ success: true, data: posts })
}
// app/blog/page.tsx
async function BlogPage() {
const res = await fetch('http://localhost:3000/api/posts')
const { data } = await res.json()
return <div>{/* Render post list */}</div>
}
// ✅ Recommended: Server Component queries directly
// app/blog/page.tsx
async function BlogPage() {
const posts = await db.post.findMany() // Query database directly
return <div>{/* Render post list */}</div>
}When do you need Route Handlers?
- External calls: Providing endpoints for mobile apps, third-party services
- Webhooks: Receiving callbacks from third-party services
- Client component data mutations: Form submissions, delete operations
- Complex business logic: File uploads, calling multiple external APIs
When don’t you need Route Handlers?
- Server Component data fetching: Querying database directly is faster
- Simple form submissions: Server Actions are simpler
- Internal page navigation: Just use Next.js routing
Advanced Techniques: Making APIs More Professional
Input Validation
In previous examples, parameter validation was all manual if checks—too cumbersome. I now use Zod for validation:
import { z } from 'zod'
// Define validation rules
const createUserSchema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
password: z.string().min(8),
age: z.number().min(18).optional()
})
export async function POST(request: Request) {
const body = await request.json()
// Validate data
const result = createUserSchema.safeParse(body)
if (!result.success) {
return Response.json({
success: false,
error: 'Data validation failed',
details: result.error.errors // Return detailed validation errors
}, { status: 400 })
}
// result.data is validated data, type-safe
const user = await db.user.create({ data: result.data })
return Response.json({ success: true, data: user }, { status: 201 })
}Zod’s benefit is validation and type definition in one:
// Infer TypeScript type from schema
type CreateUserInput = z.infer<typeof createUserSchema>
// Equivalent to:
// type CreateUserInput = {
// username: string
// email: string
// password: string
// age?: number
// }Middleware Pattern
After writing several endpoints, you’ll notice lots of repetitive code—authentication, logging, error handling. That’s when you can abstract into middleware:
// lib/middleware.ts
type RouteHandler = (request: Request, context: any) => Promise<Response>
// Auth middleware
export function withAuth(handler: RouteHandler): RouteHandler {
return async (request, context) => {
const token = request.headers.get('authorization')
if (!token) {
return Response.json(
{ success: false, error: 'Not logged in' },
{ status: 401 }
)
}
// Verify token
const user = await verifyToken(token)
if (!user) {
return Response.json(
{ success: false, error: 'Invalid token' },
{ status: 401 }
)
}
// Pass user info to handler
context.user = user
return handler(request, context)
}
}
// Logging middleware
export function withLogging(handler: RouteHandler): RouteHandler {
return async (request, context) => {
const start = Date.now()
const { method, url } = request
console.log(`[${method}] ${url} - Starting`)
const response = await handler(request, context)
const duration = Date.now() - start
console.log(`[${method}] ${url} - Completed (${duration}ms)`)
return response
}
}
// Composition usage
// app/api/profile/route.ts
import { withAuth, withLogging } from '@/lib/middleware'
async function getProfile(request: Request, context: any) {
const user = context.user // Get user info from middleware
return Response.json({ success: true, data: user })
}
export const GET = withLogging(withAuth(getProfile))This pattern is quite useful. In actual projects I combine it with Zod validation:
// lib/middleware.ts
export function withValidation<T>(
schema: z.Schema<T>,
handler: (request: Request, data: T, context: any) => Promise<Response>
): RouteHandler {
return async (request, context) => {
const body = await request.json()
const result = schema.safeParse(body)
if (!result.success) {
return Response.json({
success: false,
error: 'Data validation failed',
details: result.error.errors
}, { status: 400 })
}
return handler(request, result.data, context)
}
}
// Usage
export const POST = withAuth(
withValidation(createUserSchema, async (request, data, context) => {
// data is validated, type-safe
const user = await db.user.create({ data })
return Response.json({ success: true, data: user }, { status: 201 })
})
)Choosing Edge Runtime
Next.js supports two runtimes: Node.js Runtime and Edge Runtime. In most cases the default Node.js Runtime is sufficient, but if your API needs global fast responses, consider Edge Runtime:
// app/api/hello/route.ts
export const runtime = 'edge' // Specify Edge Runtime
export async function GET() {
return Response.json({ message: 'Hello from Edge!' })
}Edge Runtime’s advantage is fast response, as code deploys to edge nodes closest to users. However, there are limitations:
- Can’t use Node.js APIs: Modules like
fs,pathare unavailable - Can’t connect traditional databases: Need databases with HTTP connections, like Prisma Data Proxy, PlanetScale
- Bundle size limits: Code can’t be too large or deployment will fail
When to use Edge Runtime?
- Simple APIs without complex dependencies
- Read-heavy scenarios
- Need global low latency
When to use Node.js Runtime?
- Need to connect traditional databases
- Need Node.js ecosystem libraries
- Complex business logic
Honestly, I use the default Node.js Runtime for most projects. Edge Runtime is currently suited for specific scenarios.
Conclusion
That covers the core content of Next.js API Routes. Let’s recap:
- Approach changes: From Pages Router’s
req/resto App Router’sRequest/Response, embracing Web standards - Route Handlers: Independent exports per HTTP method, clear structure, supporting GET, POST, PUT, PATCH, DELETE, etc.
- Request handling: URL parameters, JSON body, FormData, Headers, Cookies, dynamic route params—all data access methods
- Error handling: Distinguish expected from unexpected errors, unified response formats, avoid leaking sensitive info
- Response design: Follow RESTful principles, semantic status codes, TypeScript type sharing
- Common gotchas: GET caching, deployment 404s, redirect in try-catch failures, overusing Route Handlers
Honestly, Next.js API approach from Pages Router to App Router changed significantly, it’s a bit uncomfortable at first. But after using it for a while, you’ll find the new approach better aligns with modern web development thinking—more type-safe, clearer code, more flexible deployment.
Next steps for you:
- Try it now: Rewrite one of your project’s endpoints using Route Handlers, experience the difference in the new approach
- Build templates: Based on this article’s error handling and response format code, organize your own API template set for future reuse
- Continuous learning: Next.js is rapidly evolving, follow official docs updates, learn about Server Actions, Middleware and other new features
Remember, there’s no silver bullet for writing APIs. This article’s approaches aren’t the only solutions. Flexibly adjust based on your project’s actual situation to find what works best for your team.
If this article helped you, bookmark it for when you hit issues. Wishing you smooth sailing with Next.js development!
FAQ
What are the main differences between Route Handlers and Pages Router API Routes?
Why doesn't my GET request return the latest data?
When should I use Route Handlers vs Server Components?
How do I handle API errors gracefully?
All my APIs return 404 after deployment but work locally, what now?
11 min read · Published on: Jan 5, 2026 · Modified on: Jan 22, 2026
Related Posts
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation

Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload

Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Unit Testing Guide: Complete Jest + React Testing Library Setup


Comments
Sign in with GitHub to leave a comment