Next.js Route Handlers: Byg Professionelle API-Endpoints i App Router

Lær at bygge professionelle API-endpoints i Next.js App Router med Route Handlers. Dækker webhook-håndtering med Stripe, streaming, CORS, rate limiting og autentificering med kodeeksempler for Next.js 16.

Next.js 16 Route Handlers: API Setup 2026

Hvorfor Route Handlers Er Uundværlige i Next.js App Router

Okay, lad os starte med det åbenlyse spørgsmål. Du har Server Actions i App Router, og de klarer det meste af serverside-logikken. Så hvorfor overhovedet bekymre sig om Route Handlers?

Tænk over det her: Stripe skal sende en webhook til din app. Din mobilapp har brug for et REST API. Eller du vil streame realtidsdata til klienten. Server Actions kan simpelthen ikke håndtere de scenarier.

Det er præcis her Route Handlers kommer ind i billedet. De er App Routerens svar på API-endpoints — bygget oven på Web Request og Response API'erne i stedet for den gamle Express-lignende req/res-model fra Pages Router. Og med Next.js 16.2 er de ærligt talt mere kraftfulde end nogensinde.

I denne guide gennemgår vi alt fra grundlæggende opsætning til avancerede mønstre som webhook-verificering med Stripe, streaming-responses, CORS-konfiguration og rate limiting. Så lad os dykke ned i det.

Grundlæggende Opsætning: Din Første Route Handler

Route Handlers defineres i route.ts-filer (eller route.js, hvis du ikke bruger TypeScript) placeret i app/-mappen. Hver fil eksporterer funktioner navngivet efter HTTP-metoder: GET, POST, PUT, PATCH, DELETE, HEAD og OPTIONS.

Her er det simpleste eksempel du kan lave:

// app/api/hello/route.ts
export async function GET() {
  return Response.json({ message: 'Hej fra Next.js!' })
}

Det er bogstaveligt talt alt, der skal til. Besøg /api/hello og du får et JSON-svar tilbage. Ingen konfiguration, ingen middleware-opsætning — det virker bare. Ret befriende, faktisk.

Filstruktur og Routing

Route Handlers følger den samme filsystem-baserede routing som sider i App Router. Placeringen af din route.ts-fil bestemmer URL'en:

app/
├── api/
│   ├── users/
│   │   ├── route.ts          → /api/users
│   │   └── [id]/
│   │       └── route.ts      → /api/users/:id
│   ├── posts/
│   │   └── route.ts          → /api/posts
│   └── webhook/
│       └── stripe/
│           └── route.ts      → /api/webhook/stripe

Én vigtig ting at huske: du kan ikke have både en route.ts og en page.tsx i samme mappe. De to er gensidigt ekskluderende på samme rute-segment. Det har bidt mig mere end én gang.

Adgang til Request-data

For at arbejde med forespørgselsdata bruger du NextRequest fra next/server. Den udvider den native Request med nogle praktiske ekstra metoder:

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

export async function GET(request: NextRequest) {
  // Query-parametre
  const searchParams = request.nextUrl.searchParams
  const page = searchParams.get('page') ?? '1'
  const limit = searchParams.get('limit') ?? '10'

  // Headers
  const authHeader = request.headers.get('authorization')

  // Cookies
  const sessionToken = request.cookies.get('session')

  return Response.json({
    page: parseInt(page),
    limit: parseInt(limit),
    authenticated: !!authHeader,
  })
}

HTTP-Metoder og CRUD-Operationer

En af de største fordele ved Route Handlers sammenlignet med Server Actions er fuld kontrol over HTTP-metoder. Det lyder måske simpelt, men det gør en kæmpe forskel når du bygger et egentligt API.

Lad os bygge et komplet CRUD API med ordentlig validering:

// app/api/posts/route.ts
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'

const postSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  published: z.boolean().optional().default(false),
})

// Hent alle indlæg
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 offset = (page - 1) * limit

  const posts = await db.query(
    'SELECT * FROM posts ORDER BY created_at DESC LIMIT $1 OFFSET $2',
    [limit, offset]
  )

  const total = await db.query('SELECT COUNT(*) FROM posts')

  return Response.json({
    data: posts,
    pagination: {
      page,
      limit,
      total: total[0].count,
    },
  })
}

// Opret nyt indlæg
export async function POST(request: NextRequest) {
  const body = await request.json()
  const result = postSchema.safeParse(body)

  if (!result.success) {
    return Response.json(
      { error: 'Valideringsfejl', details: result.error.flatten() },
      { status: 400 }
    )
  }

  const post = await db.query(
    'INSERT INTO posts (title, content, published) VALUES ($1, $2, $3) RETURNING *',
    [result.data.title, result.data.content, result.data.published]
  )

  return Response.json(post[0], { status: 201 })
}

Dynamiske Ruter med Parametre

Her er en ting der fanger mange udviklere: i Next.js 16 er params asynkrone. Du skal await'e dem før brug — ellers får du en fejl der kan være ret forvirrende første gang:

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

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

  const post = await db.query(
    'SELECT * FROM posts WHERE id = $1',
    [id]
  )

  if (!post.length) {
    return Response.json(
      { error: 'Indlæg ikke fundet' },
      { status: 404 }
    )
  }

  return Response.json(post[0])
}

export async function PUT(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const body = await request.json()

  const updated = await db.query(
    'UPDATE posts SET title = $1, content = $2 WHERE id = $3 RETURNING *',
    [body.title, body.content, id]
  )

  if (!updated.length) {
    return Response.json(
      { error: 'Indlæg ikke fundet' },
      { status: 404 }
    )
  }

  return Response.json(updated[0])
}

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

  await db.query('DELETE FROM posts WHERE id = $1', [id])
  return new Response(null, { status: 204 })
}

Webhook-Håndtering: Stripe, GitHub og Andre

Webhooks er nok den vigtigste grund til at bruge Route Handlers. Tænk over det — externe tjenester som Stripe, GitHub og Twilio kan ikke bare kalde en Server Action. De har brug for et rigtigt HTTP-endpoint, de kan sende POST-requests til.

Her er en sikker Stripe webhook-handler (og ja, signaturverificering er absolut nødvendig — spring den aldrig over):

// app/api/webhook/stripe/route.ts
import { NextRequest } from 'next/server'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: NextRequest) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')

  if (!signature) {
    return Response.json(
      { error: 'Manglende signatur' },
      { status: 400 }
    )
  }

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    console.error('Webhook signatur-verificering fejlede:', err)
    return Response.json(
      { error: 'Ugyldig signatur' },
      { status: 400 }
    )
  }

  // Håndtér de forskellige event-typer
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      await handleSuccessfulPayment(session)
      break
    }
    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription
      await handleSubscriptionUpdate(subscription)
      break
    }
    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice
      await handleFailedPayment(invoice)
      break
    }
    default:
      console.log('Ubehandlet event-type:', event.type)
  }

  return Response.json({ received: true })
}

Et vigtigt punkt om body-parsing, som tripper mange: Request-body'en er en stream, der kun kan læses én gang. Til webhook-verificering skal du bruge request.text() for at få den rå streng — ikke request.json(). Hvis du parser body'en først, kan du ikke bagefter verificere signaturen. Det er en klassisk fejl.

Streaming-Responses: Realtidsdata med Web Streams

Route Handlers understøtter streaming via Web Streams API, og det er ærligt talt en af de fedeste features. Det er perfekt til Server-Sent Events, AI-genereret indhold og realtidsopdateringer.

Lad os se på et grundlæggende SSE-eksempel:

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

  const stream = new ReadableStream({
    async start(controller) {
      // Simuler realtidsdata
      for (let i = 0; i < 10; i++) {
        const data = JSON.stringify({
          id: i,
          timestamp: new Date().toISOString(),
          message: `Besked ${i + 1}`,
        })

        controller.enqueue(
          encoder.encode(`data: ${data}\n\n`)
        )

        // Vent et sekund mellem beskeder
        await new Promise((resolve) => setTimeout(resolve, 1000))
      }

      controller.close()
    },
  })

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

Streaming med AI/LLM-Integration

Et af de mest populære use cases for streaming lige nu er AI-chatbots. Hvis du bygger noget med en LLM, er det her det hele starter:

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

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

  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'gpt-4o',
      messages: [{ role: 'user', content: message }],
      stream: true,
    }),
  })

  // Videresend streamen direkte til klienten
  return new Response(response.body, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
    },
  })
}

CORS-Konfiguration for Eksterne Klienter

Når din API skal tilgås fra andre domæner — en mobilapp, en separat frontend, hvad det nu måtte være — skal du konfigurere CORS-headers. Det er ikke svært, men det er nemt at glemme (og så sidder du og undrer dig over hvorfor din fetch fejler med en mystisk CORS-fejl).

Der er to tilgange, afhængigt af dit behov:

Per-Route CORS

// app/api/public/data/route.ts
const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://din-frontend.dk',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}

export async function OPTIONS() {
  return new Response(null, {
    status: 204,
    headers: corsHeaders,
  })
}

export async function GET() {
  const data = await fetchData()

  return Response.json(data, {
    headers: corsHeaders,
  })
}

Global CORS via Middleware

Hvis mange af dine API-ruter har brug for CORS, er middleware den bedre løsning. Det sparer dig for at gentage de samme headers i hver eneste route-fil:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'

const allowedOrigins = [
  'https://din-frontend.dk',
  'https://app.din-frontend.dk',
]

export function middleware(request: NextRequest) {
  // Kun CORS for API-ruter
  if (!request.nextUrl.pathname.startsWith('/api/')) {
    return NextResponse.next()
  }

  const origin = request.headers.get('origin') ?? ''
  const isAllowed = allowedOrigins.includes(origin)

  // Håndtér preflight
  if (request.method === 'OPTIONS') {
    return new NextResponse(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': isAllowed ? origin : '',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Max-Age': '86400',
      },
    })
  }

  const response = NextResponse.next()
  if (isAllowed) {
    response.headers.set('Access-Control-Allow-Origin', origin)
  }

  return response
}

export const config = {
  matcher: '/api/:path*',
}

Rate Limiting: Beskyt Dine Endpoints

Her er noget mange glemmer i starten: uden rate limiting er dine API-endpoints åbne for misbrug. Og det behøver ikke være et stort DDoS-angreb — selv en enkelt bruger med en forkert konfigureret klient kan overbelaste dit system.

Her er en produktionsklar løsning med Upstash Redis:

// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

export const rateLimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '60 s'), // 10 forespørgsler per minut
  analytics: true,
})

// Hjælpefunktion til Route Handlers
export async function checkRateLimit(identifier: string) {
  const { success, limit, remaining, reset } = await rateLimit.limit(identifier)

  const headers = {
    'X-RateLimit-Limit': limit.toString(),
    'X-RateLimit-Remaining': remaining.toString(),
    'X-RateLimit-Reset': reset.toString(),
  }

  return { success, headers }
}
// app/api/protected/route.ts
import { NextRequest } from 'next/server'
import { checkRateLimit } from '@/lib/rate-limit'

export async function GET(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
  const { success, headers } = await checkRateLimit(ip)

  if (!success) {
    return Response.json(
      { error: 'For mange forespørgsler. Prøv igen senere.' },
      { status: 429, headers }
    )
  }

  const data = await fetchProtectedData()
  return Response.json(data, { headers })
}

Autentificering i Route Handlers

For endpoints der kræver brugerautentificering, kan du verificere sessionen direkte i handleren. Intet fancy — bare tjek om brugeren er logget ind:

// app/api/user/profile/route.ts
import { NextRequest } from 'next/server'
import { auth } from '@/lib/auth'

export async function GET(request: NextRequest) {
  const session = await auth()

  if (!session?.user) {
    return Response.json(
      { error: 'Ikke autoriseret' },
      { status: 401 }
    )
  }

  const profile = await db.query(
    'SELECT * FROM profiles WHERE user_id = $1',
    [session.user.id]
  )

  return Response.json(profile)
}

export async function PATCH(request: NextRequest) {
  const session = await auth()

  if (!session?.user) {
    return Response.json(
      { error: 'Ikke autoriseret' },
      { status: 401 }
    )
  }

  const body = await request.json()

  const updated = await db.query(
    'UPDATE profiles SET name = $1, bio = $2 WHERE user_id = $3 RETURNING *',
    [body.name, body.bio, session.user.id]
  )

  return Response.json(updated[0])
}

Caching og Statisk Eksport

Noget der overrasker mange: i Next.js 16 er GET Route Handlers dynamiske som standard. De kører ved hver eneste forespørgsel. Hvis du vil have caching, skal du eksplicit bede om det:

// app/api/products/route.ts
// Statisk cachet ved build-time
export const dynamic = 'force-static'
export const revalidate = 3600 // Revalidér hver time

export async function GET() {
  const products = await fetchProducts()
  return Response.json(products)
}

Du kan også bruge det nyere use cache-direktiv, som giver mere finkornet kontrol:

// app/api/categories/route.ts
import { cacheLife } from 'next/cache'

export async function GET() {
  'use cache'
  cacheLife('hours')

  const categories = await db.query('SELECT * FROM categories')
  return Response.json(categories)
}

For statiske eksporter kan GET Route Handlers uden dynamisk data eksporteres som statiske filer. Det var faktisk ikke muligt med Pages Routerens API Routes, så det er en pæn forbedring.

Edge Runtime vs. Node.js Runtime

Du kan vælge hvilken runtime din Route Handler kører på, og det valg har konsekvenser:

// Edge Runtime — lav latency, begrænset API
export const runtime = 'edge'

export async function GET(request: Request) {
  // Kører tæt på brugeren, men uden adgang til
  // Node.js-specifikke API'er som fs eller child_process
  return Response.json({ region: 'edge' })
}

Min anbefaling? Vælg Edge Runtime til simple, latency-følsomme endpoints som geolokation eller A/B-test. Vælg Node.js Runtime (som er standard) til alt med databaseforespørgsler, filoperationer og tung beregning. I de fleste tilfælde er Node.js det rigtige valg.

Route Handlers vs. Server Actions: Hvornår Bruger Du Hvad?

Det her spørgsmål dukker op konstant, så lad os få det afklaret en gang for alle.

  • Server Actions: Brug til interne mutationer kaldt fra React-komponenter. De giver automatisk typesikkerhed, CSRF-beskyttelse og er simplere at implementere. Dækker ærligt talt 90% af serverside-logikken i de fleste apps.
  • Route Handlers: Brug når du har brug for offentlige API-endpoints, webhook-modtagelse, eksplicit HTTP-metode kontrol, streaming, CORS, eller når eksterne klienter (mobilapps, tredjepartstjenester) skal tilgå dit API.

Tommelfingerregel: Start med Server Actions til interne mutationer. Skift kun til Route Handlers når du faktisk har brug for eksplicit HTTP-semantik eller eksterne adgangspunkter. Gør det ikke mere kompliceret end nødvendigt.

FormData, Filupload og Ikke-JSON Responses

Route Handlers håndterer langt mere end JSON. Du kan arbejde med FormData til filupload og returnere mange forskellige responstyper:

// app/api/upload/route.ts
import { NextRequest } from 'next/server'
import { writeFile } from 'fs/promises'
import { join } from 'path'

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

  if (!file) {
    return Response.json(
      { error: 'Ingen fil uploadet' },
      { status: 400 }
    )
  }

  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)

  const uploadDir = join(process.cwd(), 'public', 'uploads')
  const filePath = join(uploadDir, file.name)
  await writeFile(filePath, buffer)

  return Response.json({
    message: 'Fil uploadet',
    filename: file.name,
    size: file.size,
  })
}

Returnér Ikke-JSON Indhold

Route Handlers kan også returnere ting som sitemaps, RSS-feeds og andre formater. Her er et sitemap-eksempel:

// app/sitemap.xml/route.ts
export async function GET() {
  const posts = await db.query('SELECT slug, updated_at FROM posts WHERE published = true')

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  ${posts.map((post: any) => `
  <url>
    <loc>https://din-side.dk/blog/${post.slug}</loc>
    <lastmod>${post.updated_at}</lastmod>
  </url>
  `).join('')}
</urlset>`

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
    },
  })
}

Fejlhåndtering og Best Practices

God fejlhåndtering er det der adskiller et prototype-API fra et produktions-API. Her er et mønster jeg har haft god erfaring med:

// lib/api-utils.ts
export class ApiError extends Error {
  constructor(
    public statusCode: number,
    message: string
  ) {
    super(message)
  }
}

export function handleApiError(error: unknown) {
  if (error instanceof ApiError) {
    return Response.json(
      { error: error.message },
      { status: error.statusCode }
    )
  }

  console.error('Uventet fejl:', error)
  return Response.json(
    { error: 'Intern serverfejl' },
    { status: 500 }
  )
}

// Brug i Route Handler
export async function GET() {
  try {
    const data = await fetchData()
    return Response.json(data)
  } catch (error) {
    return handleApiError(error)
  }
}

Tjekliste for Produktions-API'er

  • Validér altid input med et bibliotek som Zod — stol aldrig på klientdata
  • Verificér webhook-signaturer — det er ikke valgfrit, det er et krav
  • Implementér rate limiting med Redis/Upstash i produktion
  • Sæt korrekte HTTP-statuskoder (201 for oprettet, 204 for slettet, 400 for dårlig input, 401 for uautoriseret, 429 for rate limit)
  • Log fejl med kontekst, men eksponér aldrig stack traces til klienten
  • Brug Edge Runtime kun til simple operationer — Node.js Runtime til alt med databaseadgang
  • Husk at request.body er en stream og kun kan læses én gang (det glemmer alle mindst én gang)

Ofte Stillede Spørgsmål

Hvad er forskellen mellem Route Handlers og API Routes i Pages Router?

Den korte version: Route Handlers i App Router bruger Web Request/Response API'erne (standard web-API'er), mens API Routes i Pages Router bruger Node.js-specifikke req/res-objekter. Route Handlers understøtter desuden Edge Runtime, statisk eksport og integrerer med use cache-direktivet. Hvis du migrerer fra Pages Router, skal du ændre funktionssignaturen og bruge Response.json() i stedet for res.json().

Kan jeg bruge Route Handlers sammen med Server Actions i samme projekt?

Absolut, og det anbefales faktisk. Brug Server Actions til interne mutationer fra React-komponenter (formularer, knapper, dataopdateringer) og Route Handlers til eksterne integrationer (webhooks, offentlige API'er, mobilklienter). De to supplerer hinanden rigtig godt.

Hvordan håndterer jeg autentificering i Route Handlers?

Du kan kalde din auth-funktion direkte i Route Handleren — f.eks. auth() fra Auth.js eller currentUser() fra Clerk. For webhooks bruger du signaturverificering fra den pågældende tjeneste. For API-nøgle-baseret adgang verificerer du Authorization-headeren manuelt.

Er Route Handlers cachede som standard i Next.js 16?

Nej — og det er en ændring fra tidligere versioner. I Next.js 16 er GET Route Handlers dynamiske som standard, dvs. de udføres ved hver forespørgsel. Du skal eksplicit opt-in til caching med export const dynamic = 'force-static', revalidate-eksport, eller use cache-direktivet.

Kan Route Handlers køre på Edge Runtime?

Ja. Tilføj export const runtime = 'edge' i din route-fil. Edge Runtime kører tættere på brugeren og giver lavere latency, men husk begrænsningerne — du kan ikke bruge Node.js-specifikke API'er som fs, child_process eller visse npm-pakker. Bedst til simple, latency-følsomme endpoints.

Om Forfatteren Editorial Team

Our team of expert writers and editors.