Next.js Route Handlers: Πλήρης Οδηγός για REST APIs στο App Router

Μάθε πώς να χτίζεις production-ready REST APIs με τα Route Handlers του Next.js 16 στο App Router. Πρακτικός οδηγός με CRUD, Zod validation, authentication, caching, streaming, και ολοκληρωμένο παράδειγμα με Prisma.

Εισαγωγή: Γιατί τα Route Handlers Είναι Απαραίτητα

Αν έχεις δουλέψει ήδη με Server Actions στο Next.js (τα είχαμε καλύψει σε προηγούμενο άρθρο), τότε ξέρεις πόσο εύκολα γίνονται τα mutations μέσα στην εφαρμογή — χωρίς χειροκίνητα endpoints. Αλλά εδώ έρχεται το μεγάλο ερώτημα: τι γίνεται όταν χρειάζεσαι ένα κανονικό REST API; Τι κάνεις όταν ένα mobile app, ένας τρίτος client, ή ένα webhook χρειάζεται πρόσβαση στον server σου;

Εκεί ακριβώς μπαίνουν τα Route Handlers.

Είναι ο τρόπος που το Next.js 16 σου δίνει για να φτιάχνεις custom HTTP endpoints μέσα στο App Router — με υποστήριξη για όλα τα HTTP methods (GET, POST, PUT, PATCH, DELETE), headers, cookies, streaming, και ό,τι άλλο θα περίμενες από ένα σύγχρονο API framework. Ειλικρινά, η εμπειρία είναι εντυπωσιακά απλή σε σχέση με το τι είχαμε παλιότερα.

Σκέψου τα Route Handlers σαν τον αντικαταστάτη των παλιών API Routes του Pages Router, αλλά ενσωματωμένα φυσικά στο file-based routing του App Router. Αν οι Server Actions είναι ο γρήγορος δρόμος για mutations εντός εφαρμογής, τα Route Handlers είναι η πλήρης λύση για δημόσια APIs, integrations, και σύνθετη server-side λογική.

Route Handlers vs Server Actions: Πότε Χρησιμοποιείς Τι

Πριν προχωρήσουμε παρακάτω, ας ξεκαθαρίσουμε γρήγορα τη διαφορά — είναι μια ερώτηση που βλέπω να προκύπτει συνεχώς:

  • Server Actions: Ιδανικά για mutations (create, update, delete) μέσα στην ίδια τη Next.js εφαρμογή. Καλούνται από forms ή client components χωρίς χειροκίνητο fetch. Υποστηρίζουν progressive enhancement και λειτουργούν μόνο ως POST.
  • Route Handlers: Ιδανικά για δημόσια APIs, webhooks, integrations με τρίτους, mobile apps, GET endpoints που επιστρέφουν data, και οποιαδήποτε λογική χρειάζεται πλήρη έλεγχο πάνω στο HTTP request/response cycle.

Ο κανόνας είναι αρκετά απλός: αν ο consumer είναι η ίδια η Next.js εφαρμογή σου και κάνεις mutation, πήγαινε με Server Actions. Αν ο consumer είναι οτιδήποτε εκτός της εφαρμογής (ή χρειάζεσαι GET endpoint), πήγαινε με Route Handlers.

Και φυσικά — μπορείς να χρησιμοποιείς και τα δύο στο ίδιο project χωρίς κανένα πρόβλημα.

Δομή Αρχείων και Βασική Ρύθμιση

Τα Route Handlers ζουν μέσα στο app/ directory, σε αρχεία με όνομα route.tsroute.js). Η τοποθεσία του αρχείου καθορίζει το URL path, ακριβώς όπως τα page.tsx αρχεία.

app/
├── api/
│   ├── users/
│   │   ├── route.ts          → GET/POST /api/users
│   │   └── [id]/
│   │       └── route.ts      → GET/PUT/DELETE /api/users/:id
│   ├── posts/
│   │   ├── route.ts          → GET/POST /api/posts
│   │   └── [slug]/
│   │       └── route.ts      → GET/PUT/DELETE /api/posts/:slug
│   └── webhooks/
│       └── stripe/
│           └── route.ts      → POST /api/webhooks/stripe
└── page.tsx

Σημαντικό: Δεν είσαι υποχρεωμένος να βάζεις το prefix api/. Μπορείς να τοποθετήσεις ένα route.ts οπουδήποτε μέσα στο app/. Ωστόσο, ένα route.ts και ένα page.tsx δεν μπορούν να συνυπάρχουν στον ίδιο φάκελο — αν το δοκιμάσεις, θα πάρεις conflict error. Γι' αυτό η convention app/api/ είναι τόσο πρακτική: διαχωρίζει σαφώς τα API endpoints από τις σελίδες.

Λοιπόν, ας δούμε το πιο βασικό Route Handler:

// app/api/health/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
  return NextResponse.json({
    status: 'ok',
    timestamp: new Date().toISOString(),
    version: '1.0.0',
  })
}

Αυτό είναι! Κάνοντας GET /api/health, παίρνεις ένα JSON response. Κανένα configuration, κανένα boilerplate — απλά export μια async function με το όνομα του HTTP method. Δεν γίνεται πιο απλό.

HTTP Methods: GET, POST, PUT, PATCH, DELETE

Κάθε HTTP method αντιστοιχεί σε μια exported function με το ίδιο ακριβώς όνομα. Μπορείς να ορίσεις πολλαπλά methods στο ίδιο αρχείο route.ts:

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'

// GET /api/posts - Λίστα posts
export async function GET(request: NextRequest) {
  const posts = await db.post.findMany({
    orderBy: { createdAt: 'desc' },
    take: 20,
  })

  return NextResponse.json(posts)
}

// POST /api/posts - Δημιουργία νέου post
export async function POST(request: NextRequest) {
  const body = await request.json()

  const post = await db.post.create({
    data: {
      title: body.title,
      content: body.content,
      published: false,
    },
  })

  return NextResponse.json(post, { status: 201 })
}

Για μεμονωμένο resource (π.χ. ένα συγκεκριμένο post), χρησιμοποιείς dynamic route:

// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'

// GET /api/posts/:id
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params

  const post = await db.post.findUnique({ where: { id } })

  if (!post) {
    return NextResponse.json(
      { error: 'Post not found' },
      { status: 404 }
    )
  }

  return NextResponse.json(post)
}

// PUT /api/posts/:id - Πλήρης ενημέρωση
export async function PUT(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const body = await request.json()

  const post = await db.post.update({
    where: { id },
    data: {
      title: body.title,
      content: body.content,
      published: body.published,
    },
  })

  return NextResponse.json(post)
}

// PATCH /api/posts/:id - Μερική ενημέρωση
export async function PATCH(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const body = await request.json()

  const post = await db.post.update({
    where: { id },
    data: body,
  })

  return NextResponse.json(post)
}

// DELETE /api/posts/:id
export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params

  await db.post.delete({ where: { id } })

  return new NextResponse(null, { status: 204 })
}

Πρόσεξε τη διαφορά PUT vs PATCH: το PUT αντικαθιστά ολόκληρο το resource, ενώ το PATCH ενημερώνει μόνο τα πεδία που στέλνεις. Σαν REST convention αξίζει να την ακολουθείς — κάνει τα APIs σου πολύ πιο κατανοητά σε όποιον τα καταναλώνει.

Dynamic Routes με Params στο Next.js 16

Μία από τις πιο σημαντικές αλλαγές στο Next.js 16 (και μία που πιάνει πολλούς απροετοίμαστους) είναι ότι τα params είναι πλέον Promise. Πρέπει να κάνεις await πριν τα χρησιμοποιήσεις — δεν μπορείς πια να τα προσπελάσεις σύγχρονα. Η αλλαγή ξεκίνησε στο Next.js 15 ως deprecation warning, αλλά στο 16 είναι πλέον υποχρεωτική.

// Next.js 16 - Σωστός τρόπος (async params)
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  // ...
}

// Παλιός τρόπος (ΔΕΝ λειτουργεί πια στο Next.js 16)
// export async function GET(
//   request: NextRequest,
//   { params }: { params: { slug: string } }
// ) {
//   const { slug } = params // ❌ Error
// }

Για nested dynamic routes, τα params περιέχουν όλα τα segments μαζί:

// app/api/users/[userId]/posts/[postId]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ userId: string; postId: string }> }
) {
  const { userId, postId } = await params

  const post = await db.post.findFirst({
    where: {
      id: postId,
      authorId: userId,
    },
  })

  return NextResponse.json(post)
}

Parsing Request: Query Params, Body, Headers, Cookies

Ένα σωστά δομημένο API χρειάζεται να διαβάζει δεδομένα από πολλαπλές πηγές. Ας δούμε κάθε μία ξεχωριστά.

Query Parameters

// GET /api/posts?page=2&limit=10&category=tech
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams

  const page = parseInt(searchParams.get('page') ?? '1')
  const limit = parseInt(searchParams.get('limit') ?? '10')
  const category = searchParams.get('category')

  const skip = (page - 1) * limit

  const where = category ? { category } : {}

  const [posts, total] = await Promise.all([
    db.post.findMany({ where, skip, take: limit }),
    db.post.count({ where }),
  ])

  return NextResponse.json({
    data: posts,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  })
}

Request Body (JSON)

export async function POST(request: NextRequest) {
  const body = await request.json()
  // body είναι ήδη parsed ως JavaScript object
  console.log(body.title, body.content)
  // ...
}

Request Body (FormData)

export async function POST(request: NextRequest) {
  const formData = await request.formData()
  const name = formData.get('name') as string
  const avatar = formData.get('avatar') as File

  if (avatar) {
    const bytes = await avatar.arrayBuffer()
    const buffer = Buffer.from(bytes)
    // Αποθήκευση αρχείου...
  }
  // ...
}

Headers και Cookies

import { cookies, headers } from 'next/headers'

export async function GET(request: NextRequest) {
  // Μέθοδος 1: Από το request object
  const authHeader = request.headers.get('authorization')
  const userAgent = request.headers.get('user-agent')

  // Μέθοδος 2: Μέσω next/headers (async στο Next.js 16)
  const headerStore = await headers()
  const acceptLanguage = headerStore.get('accept-language')

  // Cookies - επίσης async στο Next.js 16
  const cookieStore = await cookies()
  const sessionToken = cookieStore.get('session-token')?.value

  // Ορισμός cookie στο response
  const response = NextResponse.json({ data: 'hello' })
  response.cookies.set('visited', 'true', {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 1 εβδομάδα
  })

  return response
}

Μη ξεχνάς: Στο Next.js 16, τα cookies() και headers() από το next/headers είναι async functions — χρειάζονται await. Αν ξεχάσεις το await, θα πάρεις κάτι πολύ μπερδεμένα errors. Αυτή η αλλαγή είναι μέρος της γενικότερης μετάβασης σε async request APIs.

Input Validation με Zod

Ποτέ, μα ποτέ μη δέχεσαι blindly δεδομένα από τον client. Η βιβλιοθήκη Zod είναι η de facto standard για runtime validation στο TypeScript ecosystem — και για καλό λόγο. Ας δούμε πώς ενσωματώνεται:

// lib/validations/post.ts
import { z } from 'zod'

export const createPostSchema = z.object({
  title: z
    .string()
    .min(3, 'Ο τίτλος πρέπει να έχει τουλάχιστον 3 χαρακτήρες')
    .max(200, 'Ο τίτλος δεν μπορεί να υπερβαίνει τους 200 χαρακτήρες'),
  content: z
    .string()
    .min(10, 'Το περιεχόμενο πρέπει να έχει τουλάχιστον 10 χαρακτήρες'),
  category: z.enum(['tech', 'design', 'business']).optional(),
  tags: z.array(z.string()).max(5).optional(),
})

export const updatePostSchema = createPostSchema.partial()

export type CreatePostInput = z.infer<typeof createPostSchema>
export type UpdatePostInput = z.infer<typeof updatePostSchema>
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createPostSchema } from '@/lib/validations/post'

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const validated = createPostSchema.parse(body)

    const post = await db.post.create({
      data: validated,
    })

    return NextResponse.json(post, { status: 201 })
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        {
          error: 'Validation failed',
          details: error.errors.map((e) => ({
            field: e.path.join('.'),
            message: e.message,
          })),
        },
        { status: 400 }
      )
    }

    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

Αυτό το pattern — parse, catch ZodError, return structured errors — θα σε σώσει αμέτρητες φορές στην πράξη. Ο client παίρνει πίσω ακριβώς ποιο πεδίο απέτυχε και γιατί, κάτι που κάνει τεράστια διαφορά στο debugging.

Τυποποιημένο Error Handling

Ένα production API χρειάζεται consistent error responses. Εδώ έχω δει πολλούς developers να αυτοσχεδιάζουν κάθε φορά από την αρχή — ας φτιάξουμε ένα reusable pattern:

// lib/api-error.ts
export class ApiError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code?: string
  ) {
    super(message)
    this.name = 'ApiError'
  }

  static badRequest(message: string) {
    return new ApiError(400, message, 'BAD_REQUEST')
  }

  static unauthorized(message = 'Unauthorized') {
    return new ApiError(401, message, 'UNAUTHORIZED')
  }

  static forbidden(message = 'Forbidden') {
    return new ApiError(403, message, 'FORBIDDEN')
  }

  static notFound(message = 'Resource not found') {
    return new ApiError(404, message, 'NOT_FOUND')
  }

  static conflict(message: string) {
    return new ApiError(409, message, 'CONFLICT')
  }
}
// lib/api-utils.ts
import { NextResponse } from 'next/server'
import { ApiError } from './api-error'
import { ZodError } from 'zod'

type ApiHandler = (...args: any[]) => Promise<NextResponse>

export function withErrorHandler(handler: ApiHandler): ApiHandler {
  return async (...args) => {
    try {
      return await handler(...args)
    } catch (error) {
      if (error instanceof ApiError) {
        return NextResponse.json(
          {
            error: error.message,
            code: error.code,
          },
          { status: error.statusCode }
        )
      }

      if (error instanceof ZodError) {
        return NextResponse.json(
          {
            error: 'Validation failed',
            code: 'VALIDATION_ERROR',
            details: error.errors,
          },
          { status: 400 }
        )
      }

      console.error('Unhandled API error:', error)
      return NextResponse.json(
        { error: 'Internal server error', code: 'INTERNAL_ERROR' },
        { status: 500 }
      )
    }
  }
}

Τώρα κάθε Route Handler γίνεται πολύ πιο καθαρό — δες τη διαφορά:

// app/api/posts/[id]/route.ts
import { withErrorHandler } from '@/lib/api-utils'
import { ApiError } from '@/lib/api-error'

export const GET = withErrorHandler(async (request, { params }) => {
  const { id } = await params

  const post = await db.post.findUnique({ where: { id } })

  if (!post) {
    throw ApiError.notFound('Post not found')
  }

  return NextResponse.json(post)
})

Πολύ πιο καθαρό, σωστά; Κανένα try-catch boilerplate μέσα στον handler — τα χειρίζεται όλα ο wrapper.

Authentication Integration

Τα περισσότερα APIs χρειάζονται κάποια μορφή authentication. Ας δούμε πώς ενσωματώνεις auth checks στα Route Handlers σου. Θα χρησιμοποιήσουμε JWT ως παράδειγμα, αλλά η λογική είναι ίδια ανεξαρτήτως μηχανισμού:

// lib/auth.ts
import { jwtVerify } from 'jose'
import { NextRequest } from 'next/server'
import { ApiError } from './api-error'

const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET!
)

interface UserPayload {
  userId: string
  email: string
  role: 'admin' | 'user'
}

export async function authenticateRequest(
  request: NextRequest
): Promise<UserPayload> {
  const authHeader = request.headers.get('authorization')

  if (!authHeader?.startsWith('Bearer ')) {
    throw ApiError.unauthorized('Missing or invalid token')
  }

  const token = authHeader.slice(7)

  try {
    const { payload } = await jwtVerify(token, JWT_SECRET)
    return payload as unknown as UserPayload
  } catch {
    throw ApiError.unauthorized('Token expired or invalid')
  }
}

export function requireRole(user: UserPayload, role: string) {
  if (user.role !== role) {
    throw ApiError.forbidden(
      'You do not have permission to perform this action'
    )
  }
}
// app/api/admin/users/route.ts
import { withErrorHandler } from '@/lib/api-utils'
import { authenticateRequest, requireRole } from '@/lib/auth'

export const GET = withErrorHandler(async (request) => {
  const user = await authenticateRequest(request)
  requireRole(user, 'admin')

  const users = await db.user.findMany({
    select: { id: true, email: true, name: true, role: true },
  })

  return NextResponse.json(users)
})

Αν χρησιμοποιείς μια βιβλιοθήκη σαν Auth.js (NextAuth), τα πράγματα γίνονται ακόμα πιο εύκολα — μπορείς να καλέσεις απευθείας το auth() function:

import { auth } from '@/lib/auth-config'

export const GET = withErrorHandler(async () => {
  const session = await auth()

  if (!session?.user) {
    throw ApiError.unauthorized()
  }

  // Ο χρήστης είναι authenticated...
  return NextResponse.json({ user: session.user })
})

Caching και Revalidation Strategies

Εδώ έχουμε μια σημαντική αλλαγή φιλοσοφίας. Στο Next.js 16, τα GET Route Handlers δεν κάνουν cache από default — αυτό ξεκίνησε στο Next.js 15 και είναι μια συνειδητή επιλογή. Opt-in caching αντί για opt-out. Κατά τη γνώμη μου, ήταν σωστή κίνηση — το παλιό automatic caching δημιουργούσε πολύ σύγχυση.

Static Route Handler (πλήρες cache)

// app/api/config/route.ts
export const dynamic = 'force-static'

export async function GET() {
  const config = {
    appName: 'My App',
    version: '2.0.0',
    features: ['dark-mode', 'notifications'],
  }

  return NextResponse.json(config)
}

Με force-static, το response γίνεται pre-render στο build time και σερβίρεται ως static αρχείο. Τέλειο για δεδομένα που αλλάζουν σπάνια.

Time-Based Revalidation

// app/api/stats/route.ts
export const revalidate = 3600 // Revalidate κάθε 1 ώρα

export async function GET() {
  const stats = await db.post.aggregate({
    _count: true,
    _sum: { views: true },
  })

  return NextResponse.json({
    totalPosts: stats._count,
    totalViews: stats._sum.views,
    generatedAt: new Date().toISOString(),
  })
}

On-Demand Revalidation με Tags

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidateTag } from 'next/cache'

export async function GET() {
  const posts = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] },
  })

  return NextResponse.json(await posts.json())
}

export async function POST(request: NextRequest) {
  const body = await request.json()
  const post = await db.post.create({ data: body })

  // Ακυρώνουμε το cache μετά τη δημιουργία
  revalidateTag('posts')

  return NextResponse.json(post, { status: 201 })
}
// app/api/revalidate/route.ts - Webhook endpoint για revalidation
import { NextRequest, NextResponse } from 'next/server'
import { revalidateTag } from 'next/cache'

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidation-secret')

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json(
      { error: 'Invalid secret' },
      { status: 401 }
    )
  }

  const body = await request.json()
  const { tag } = body

  revalidateTag(tag)

  return NextResponse.json({
    revalidated: true,
    tag,
    timestamp: Date.now(),
  })
}

Το on-demand revalidation είναι πανίσχυρο σε πραγματικά σενάρια: ένα CMS στέλνει webhook στο /api/revalidate κάθε φορά που ενημερώνεται περιεχόμενο, και το cache ανανεώνεται στιγμιαία. Χρησιμοποιώ αυτό το pattern σε σχεδόν κάθε project.

Streaming Responses

Τα Route Handlers υποστηρίζουν και streaming — εξαιρετικά χρήσιμο για real-time data, AI-generated content, ή μεγάλα datasets που δεν θες να φορτώσεις ολόκληρα στη μνήμη:

// app/api/ai/chat/route.ts
import { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const { prompt } = await request.json()

  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      // Προσομοίωση AI streaming response
      const response = await fetch('https://api.openai.com/v1/chat/completions', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          model: 'gpt-4o',
          messages: [{ role: 'user', content: prompt }],
          stream: true,
        }),
      })

      const reader = response.body?.getReader()
      if (!reader) {
        controller.close()
        return
      }

      while (true) {
        const { done, value } = await reader.read()
        if (done) break
        controller.enqueue(value)
      }

      controller.close()
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  })
}

Μπορείς επίσης να στήσεις Server-Sent Events (SSE) για real-time updates. Αυτό είναι ιδιαίτερα βολικό για notifications ή live dashboards:

// app/api/notifications/stream/route.ts
export async function GET() {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      const sendEvent = (data: object) => {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
        )
      }

      // Στέλνε ένα heartbeat κάθε 30 δευτερόλεπτα
      const heartbeat = setInterval(() => {
        sendEvent({ type: 'heartbeat', timestamp: Date.now() })
      }, 30000)

      // Προσομοίωση notification stream
      sendEvent({ type: 'connected', message: 'Stream connected' })

      // Καθάρισμα κατά το κλείσιμο
      request.signal.addEventListener('abort', () => {
        clearInterval(heartbeat)
        controller.close()
      })
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  })
}

Edge vs Node.js Runtime

Κάθε Route Handler μπορεί να τρέχει σε δύο διαφορετικά runtimes:

// Node.js runtime (default) - πλήρης πρόσβαση στο Node.js API
export const runtime = 'nodejs'

// Edge runtime - χαμηλότερο latency, περιορισμένο API
export const runtime = 'edge'

Πότε να επιλέξεις τι:

  • Node.js runtime (default): Χρησιμοποίησέ το όταν χρειάζεσαι πλήρες Node.js API — file system access, native modules, βαριά computation, database connections μέσω TCP (π.χ. Prisma, raw PostgreSQL). Είναι η ασφαλής επιλογή για τα περισσότερα use cases.
  • Edge runtime: Χρησιμοποίησέ το για endpoints που χρειάζονται ελάχιστο latency και τρέχουν σε edge locations κοντά στον χρήστη. Ιδανικό για geo-based redirects, authentication checks, και lightweight data transformations. Ωστόσο, υπάρχουν περιορισμοί: δεν μπορείς να χρησιμοποιήσεις native Node.js modules, ο μέγιστος χρόνος εκτέλεσης είναι ~30 δευτερόλεπτα, και το μέγεθος bundle φτάνει μέχρι 4MB.
// app/api/geo/route.ts
export const runtime = 'edge'

export async function GET(request: NextRequest) {
  const country = request.geo?.country ?? 'unknown'
  const city = request.geo?.city ?? 'unknown'

  return NextResponse.json({
    country,
    city,
    greeting: country === 'GR'
      ? 'Καλώς ήρθες!'
      : 'Welcome!',
  })
}

Πλήρες Παράδειγμα: CRUD API για Blog με Prisma

Ωραία, τώρα ας ενώσουμε τα πάντα σε ένα ολοκληρωμένο, production-ready παράδειγμα. Θα φτιάξουμε ένα πλήρες blog API χρησιμοποιώντας Prisma ως ORM — κάτι που πραγματικά μπορείς να πάρεις και να χρησιμοποιήσεις σε δικό σου project.

Prisma Schema

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Post {
  id          String   @id @default(cuid())
  title       String
  slug        String   @unique
  content     String
  excerpt     String?
  published   Boolean  @default(false)
  views       Int      @default(0)
  authorId    String
  author      User     @relation(fields: [authorId], references: [id])
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

model User {
  id    String @id @default(cuid())
  email String @unique
  name  String
  role  String @default("user")
  posts Post[]
}

Validation Schemas

// lib/validations/post.ts
import { z } from 'zod'

export const createPostSchema = z.object({
  title: z.string().min(3).max(200),
  content: z.string().min(10),
  excerpt: z.string().max(300).optional(),
  published: z.boolean().optional().default(false),
})

export const updatePostSchema = createPostSchema.partial()

export const queryPostsSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(10),
  published: z
    .enum(['true', 'false'])
    .transform((v) => v === 'true')
    .optional(),
  search: z.string().optional(),
  sortBy: z.enum(['createdAt', 'title', 'views']).default('createdAt'),
  order: z.enum(['asc', 'desc']).default('desc'),
})

Database Helper

// lib/db.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log:
      process.env.NODE_ENV === 'development'
        ? ['query', 'error', 'warn']
        : ['error'],
  })

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

Slug Helper

// lib/slug.ts
export function generateSlug(title: string): string {
  return title
    .toLowerCase()
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '') // Αφαίρεση τόνων
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)+/g, '')
}

Λίστα Posts (GET) και Δημιουργία (POST)

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { withErrorHandler } from '@/lib/api-utils'
import { authenticateRequest } from '@/lib/auth'
import {
  createPostSchema,
  queryPostsSchema,
} from '@/lib/validations/post'
import { generateSlug } from '@/lib/slug'

// GET /api/posts - Λίστα posts με pagination, search, filters
export const GET = withErrorHandler(async (request: NextRequest) => {
  const searchParams = Object.fromEntries(
    request.nextUrl.searchParams.entries()
  )

  const {
    page,
    limit,
    published,
    search,
    sortBy,
    order,
  } = queryPostsSchema.parse(searchParams)

  const where: any = {}

  if (published !== undefined) {
    where.published = published
  }

  if (search) {
    where.OR = [
      { title: { contains: search, mode: 'insensitive' } },
      { content: { contains: search, mode: 'insensitive' } },
    ]
  }

  const skip = (page - 1) * limit

  const [posts, total] = await Promise.all([
    prisma.post.findMany({
      where,
      skip,
      take: limit,
      orderBy: { [sortBy]: order },
      include: {
        author: {
          select: { id: true, name: true },
        },
      },
    }),
    prisma.post.count({ where }),
  ])

  return NextResponse.json({
    data: posts,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
      hasNext: page * limit < total,
      hasPrev: page > 1,
    },
  })
})

// POST /api/posts - Δημιουργία νέου post
export const POST = withErrorHandler(async (request: NextRequest) => {
  const user = await authenticateRequest(request)
  const body = await request.json()
  const validated = createPostSchema.parse(body)

  let slug = generateSlug(validated.title)

  // Εξασφάλιση μοναδικού slug
  const existingPost = await prisma.post.findUnique({
    where: { slug },
  })
  if (existingPost) {
    slug = `${slug}-${Date.now()}`
  }

  const post = await prisma.post.create({
    data: {
      ...validated,
      slug,
      authorId: user.userId,
    },
    include: {
      author: { select: { id: true, name: true } },
    },
  })

  return NextResponse.json(post, {
    status: 201,
    headers: {
      Location: `/api/posts/${post.slug}`,
    },
  })
})

Μεμονωμένο Post (GET, PUT, DELETE)

// app/api/posts/[slug]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { withErrorHandler } from '@/lib/api-utils'
import { ApiError } from '@/lib/api-error'
import { authenticateRequest } from '@/lib/auth'
import { updatePostSchema } from '@/lib/validations/post'

type RouteParams = { params: Promise<{ slug: string }> }

// GET /api/posts/:slug
export const GET = withErrorHandler(
  async (request: NextRequest, { params }: RouteParams) => {
    const { slug } = await params

    const post = await prisma.post.findUnique({
      where: { slug },
      include: {
        author: { select: { id: true, name: true } },
      },
    })

    if (!post) {
      throw ApiError.notFound(`Post with slug "${slug}" not found`)
    }

    // Αύξηση views counter (fire and forget)
    prisma.post
      .update({
        where: { id: post.id },
        data: { views: { increment: 1 } },
      })
      .catch(() => {}) // Αγνοούμε errors στο tracking

    return NextResponse.json(post)
  }
)

// PUT /api/posts/:slug
export const PUT = withErrorHandler(
  async (request: NextRequest, { params }: RouteParams) => {
    const user = await authenticateRequest(request)
    const { slug } = await params
    const body = await request.json()
    const validated = updatePostSchema.parse(body)

    const existing = await prisma.post.findUnique({
      where: { slug },
    })

    if (!existing) {
      throw ApiError.notFound(`Post with slug "${slug}" not found`)
    }

    // Μόνο ο author ή admin μπορούν να ενημερώσουν
    if (existing.authorId !== user.userId && user.role !== 'admin') {
      throw ApiError.forbidden(
        'You can only edit your own posts'
      )
    }

    const updated = await prisma.post.update({
      where: { slug },
      data: validated,
      include: {
        author: { select: { id: true, name: true } },
      },
    })

    return NextResponse.json(updated)
  }
)

// DELETE /api/posts/:slug
export const DELETE = withErrorHandler(
  async (request: NextRequest, { params }: RouteParams) => {
    const user = await authenticateRequest(request)
    const { slug } = await params

    const existing = await prisma.post.findUnique({
      where: { slug },
    })

    if (!existing) {
      throw ApiError.notFound(`Post with slug "${slug}" not found`)
    }

    if (existing.authorId !== user.userId && user.role !== 'admin') {
      throw ApiError.forbidden(
        'You can only delete your own posts'
      )
    }

    await prisma.post.delete({ where: { slug } })

    return new NextResponse(null, { status: 204 })
  }
)

Αυτό το παράδειγμα τα περιέχει όλα: validation, authentication, authorization, error handling, pagination, και search. Είναι ένα στιβαρό foundation που μπορείς να επεκτείνεις σε οποιοδήποτε project — το έχω χρησιμοποιήσει αρκετές φορές σαν starting point και ποτέ δεν με απογοήτευσε.

Bonus Tips για Production

Πριν κλείσουμε, μερικές πρακτικές συμβουλές που κάνουν πραγματικά τη διαφορά στο production:

  • Rate limiting: Χρησιμοποίησε βιβλιοθήκες όπως @upstash/ratelimit για να προστατεύσεις τα endpoints σου. Μπορείς να το ενσωματώσεις στο withErrorHandler wrapper ή ως ξεχωριστό middleware.
  • CORS: Αν το API σου θα καταναλωθεί από διαφορετικό domain, θα χρειαστείς CORS headers. Φτιάξε ένα helper:
// lib/cors.ts
import { NextResponse } from 'next/server'

export function corsHeaders() {
  return {
    'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN ?? '*',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  }
}

// Σε κάθε route.ts που χρειάζεται CORS
export async function OPTIONS() {
  return new NextResponse(null, {
    status: 204,
    headers: corsHeaders(),
  })
}

export async function GET(request: NextRequest) {
  // ... λογική ...
  return NextResponse.json(data, { headers: corsHeaders() })
}
  • Logging: Πρόσθεσε structured logging (π.χ. με pino) στο withErrorHandler — θα σε σώσει στο debugging production issues.
  • Response compression: Το Next.js χειρίζεται αυτόματα τη συμπίεση μέσω του underlying server, αλλά βεβαιώσου ότι δεν την έχεις απενεργοποιήσει κατά λάθος.
  • Testing: Κάνε test τα Route Handlers με fetch calls ή βιβλιοθήκες σαν supertest. Μπορείς να κάνεις export τις handler functions ξεχωριστά για πιο εύκολο unit testing.

Συχνές Ερωτήσεις (FAQ)

Μπορώ να χρησιμοποιήσω Route Handlers και Server Actions μαζί;

Απολύτως! Και μάλιστα αυτή είναι η συνιστώμενη προσέγγιση. Χρησιμοποίησε Server Actions για mutations μέσα στη Next.js εφαρμογή σου (forms, client components που κάνουν create/update/delete) και Route Handlers για δημόσια APIs, webhooks, mobile app endpoints, ή οτιδήποτε χρειάζεται εξωτερική πρόσβαση. Τα δύο συμπληρώνουν το ένα το άλλο.

Γιατί τα GET Route Handlers δεν κάνουν cache από default;

Η αλλαγή ήρθε στο Next.js 15 και παραμένει στο 16. Η ομάδα του Next.js κατάλαβε ότι η αυτόματη caching προκαλούσε περισσότερα bugs και σύγχυση από όσα έλυνε (κάτι που κι εγώ είχα βιώσει αρκετές φορές). Τώρα αν θέλεις caching, το δηλώνεις ρητά με export const dynamic = 'force-static' ή export const revalidate = 3600. Πιο προβλέψιμη συμπεριφορά, λιγότερες εκπλήξεις.

Πώς κάνω testing τα Route Handlers;

Δύο δρόμοι. Ο πρώτος είναι integration testing: τρέχεις τον dev server και κάνεις πραγματικά HTTP requests με fetch. Ο δεύτερος είναι unit testing: κάνεις import τη function απευθείας και τη καλείς με mock NextRequest objects. Με Vitest, αυτό μοιάζει κάπως έτσι:

import { GET } from './route'
import { NextRequest } from 'next/server'

it('returns posts list', async () => {
  const request = new NextRequest(
    'http://localhost:3000/api/posts?page=1'
  )
  const response = await GET(request)
  const data = await response.json()

  expect(response.status).toBe(200)
  expect(data).toHaveProperty('data')
  expect(data).toHaveProperty('pagination')
})

Ποια η διαφορά proxy.ts και Route Handlers;

Στο Next.js 16, το proxy.ts τρέχει πριν από κάθε request — Route Handlers, pages, static files, τα πάντα. Είναι για cross-cutting concerns σαν authentication redirects, geo-based routing, ή headers manipulation. Τα Route Handlers είναι specific endpoints που χειρίζονται συγκεκριμένα API requests. Σκέψου το proxy.ts σαν τον φρουρό στην πόρτα και τα Route Handlers σαν τους υπαλλήλους μέσα στο κτίριο — αρκετά εύστοχη αναλογία, νομίζω.

Μπορώ να ανεβάζω αρχεία μέσω Route Handlers;

Ναι, χωρίς πρόβλημα. Χρησιμοποιείς request.formData() για να διαβάσεις τα uploaded αρχεία ως File objects, και μετά τα αποθηκεύεις τοπικά ή τα ανεβάζεις σε S3/R2/Cloudinary. Ένα πράγμα που πρέπει να προσέξεις: το default body size limit στο Next.js είναι 4MB. Μπορείς να το αυξήσεις στο next.config.ts μέσω experimental.serverActions.bodySizeLimit, ή να χρησιμοποιήσεις streaming uploads για πραγματικά μεγάλα αρχεία.

Σχετικά με τον Συγγραφέα Editorial Team

Our team of expert writers and editors.