Cloudflare Workers und Pages werden eins

Technische Details zur Plattform-Konsolidierung und konkrete Migrationsschritte

14 Minuten
Cloudflare Workers und Pages werden eins
#Cloudflare #Workers #Pages #Migration

Cloudflare konsolidiert Workers und Pages zu einer einheitlichen Plattform. Was das für bestehende Projekte bedeutet, wie die Migration technisch abläuft und warum das langfristig die bessere Architektur ist – darum geht es in diesem Artikel.

Was sind Workers und Pages überhaupt?

Bevor wir in die Migration einsteigen, kurz zur Einordnung:

Cloudflare Pages war der Einstieg für viele Entwickler: Repository verbinden, pushen, fertig. Die Plattform baute das Projekt automatisch und verteilte die statischen Dateien weltweit. Für Blogs, Dokumentationen oder Marketing-Seiten perfekt.

Cloudflare Workers sind serverlose Funktionen, die direkt am Edge laufen – also auf Cloudflares Servern weltweit, nicht in einem zentralen Rechenzentrum. Damit lassen sich APIs bauen, Requests manipulieren oder dynamische Inhalte generieren.

Das Problem: Beide Produkte nutzen unter der Haube dieselbe Technologie (Googles V8-Engine, die auch Chrome antreibt), wurden aber separat entwickelt. Wer beides brauchte – etwa eine statische Website mit ein paar API-Routen – musste zwischen zwei Welten jonglieren.

Technischer Hintergrund

Die Zusammenlegung ist keine Marketing-Aktion, sondern technisch sinnvoll. Workers und Pages liefen immer auf derselben Runtime:

AspektPagesWorkers
RuntimeV8 IsolatesV8 Isolates
Entry Point_worker.js oder functions/main in wrangler.toml
Asset HandlingImplizit (automatisch)Explizit (muss konfiguriert werden)
BuildCloudflare-seitig oder lokalImmer lokal
BindingsKV, D1, R2KV, D1, R2, Durable Objects, Queues, Cron
ObservabilityBasis-LogsLogs, Logpush, Tail Workers

Die letzte Spalte zeigt das eigentliche Problem: Pages-Nutzer hatten keinen Zugriff auf Durable Objects (persistenter Zustand am Edge), Queues (asynchrone Verarbeitung) oder Cron Triggers (zeitgesteuerte Jobs). Mit der Konsolidierung entfällt diese künstliche Einschränkung.

Was sich konkret ändert

Das Dashboard

Früher gab es zwei separate Bereiche: „Pages” und „Workers”. Jetzt erscheinen alle Projekte gemeinsam unter „Workers & Pages”. Das klingt trivial, macht aber den Alltag einfacher – kein Hin- und Herspringen mehr.

Die CLI-Befehle

Wrangler ist das Kommandozeilen-Tool für Cloudflare-Deployments. Die Befehle konvergieren:

# So war es bei Pages
wrangler pages dev      # Lokaler Entwicklungsserver
wrangler pages deploy   # Deployment zu Cloudflare

# So ist es jetzt bei Workers
wrangler dev            # Lokaler Entwicklungsserver
wrangler deploy         # Deployment zu Cloudflare

Der Unterschied ist subtil, aber wichtig: Die neuen Befehle sind kürzer und – wichtiger – sie unterstützen alle Features. wrangler pages deploy konnte keine Durable Objects deployen, wrangler deploy kann es.

Die Konfigurationsdatei

Die wrangler.toml ist das Herzstück jedes Cloudflare-Projekts. Hier definierst du, was deployed wird und wie. Die Syntax hat sich geändert:

Alte Pages-Konfiguration:

name = "meine-app"
pages_build_output_dir = "./dist/"

Das war simpel: „Nimm alles aus dem dist-Ordner und verteile es.” Aber auch limitiert – keine Kontrolle über Details.

Neue Workers-Konfiguration:

name = "meine-app"
compatibility_date = "2025-01-01"
main = "./dist/_worker.js"

[assets]
directory = "./dist/client/"
binding = "ASSETS"

Mehr Zeilen, aber auch mehr Möglichkeiten. Schauen wir uns die einzelnen Teile an:

  • name: Der Projektname, erscheint in der URL (z.B. meine-app.workers.dev)
  • compatibility_date: Wichtig für die Stabilität. Cloudflare führt regelmäßig Änderungen ein. Mit diesem Datum sagst du: „Verhalte dich so, wie am 1. Januar 2025.” Neue Features musst du explizit aktivieren.
  • main: Der Einstiegspunkt für serverseitigen Code. Bei rein statischen Seiten optional.
  • [assets]: Die neue Sektion für statische Dateien

Die Assets-Konfiguration im Detail

Die [assets]-Sektion ersetzt das implizite Verhalten von Pages. Hier hast du volle Kontrolle:

[assets]
directory = "./dist/"
binding = "ASSETS"
html_handling = "auto-trailing-slash"
not_found_handling = "single-page-application"
run_worker_first = false

Was bedeuten diese Optionen?

directory (Pflichtfeld): Wo liegen deine statischen Dateien? Nach dem Build landen die meisten Frameworks hier: ./dist/, ./build/ oder ./out/.

binding (optional): Ermöglicht Zugriff auf Assets aus deinem Worker-Code. Nützlich, wenn du z.B. eine HTML-Datei lesen und modifizieren willst, bevor du sie auslieferst.

html_handling: Wie sollen URLs behandelt werden?

WertWas passiert
auto-trailing-slash/about wird zu /about/ (Standard)
force-trailing-slashRedirect zu URL mit Slash am Ende
drop-trailing-slash/about/ wird zu /about
noneKeine Transformation

Diese Einstellung beeinflusst SEO und sollte konsistent mit deinem Framework sein. Astro nutzt standardmäßig trailing slashes, Next.js nicht.

not_found_handling: Was passiert, wenn eine Datei nicht existiert?

WertVerhalten
none404-Fehler zurückgeben (Standard)
single-page-applicationindex.html ausliefern
404-page/404.html ausliefern

Für Single-Page-Applications (React, Vue, Svelte ohne SSR) ist single-page-application essentiell. Ohne diese Einstellung funktioniert Client-Side-Routing nicht – jeder direkte Aufruf einer Route wie /dashboard würde 404 zurückgeben.

run_worker_first: Die Reihenfolge der Verarbeitung.

Standardmäßig prüft Workers zuerst, ob eine statische Datei existiert. Nur wenn nicht, wird dein Worker-Code ausgeführt. Das ist effizient – statische Dateien sind schneller.

Aber manchmal brauchst du das Gegenteil: Authentifizierung prüfen, bevor irgendwas ausgeliefert wird. Logging für alle Requests. Rate-Limiting. Dafür setzt du run_worker_first = true.

Standard:     Request → Assets prüfen → Worker (falls kein Asset) → Response
Mit Flag:     Request → Worker → Assets (falls Worker durchlässt) → Response

Die Ausführungsreihenfolge verstehen

Das ist einer der wichtigsten Unterschiede zwischen Pages und Workers, und er hat schon für Verwirrung gesorgt:

Bei Pages wurden Functions (dein Code) vor den statischen Assets geprüft. Jeder Request lief erst durch deine Middleware, dann wurden Assets gesucht.

Bei Workers ist es umgekehrt. Assets haben Priorität. Dein Worker-Code läuft nur, wenn kein passendes Asset gefunden wurde.

Warum? Performance. Die meisten Requests auf einer Website sind für statische Dateien – CSS, JavaScript, Bilder. Diese direkt auszuliefern ist schneller, als erst Code auszuführen.

Für die meisten Projekte ist das kein Problem. Aber wenn du globale Middleware brauchst (Auth-Check vor allem, auch vor statischen Assets), musst du explizit umschalten:

[assets]
directory = "./dist/"
run_worker_first = true

Migration von Pages Functions

Pages hatte ein elegantes System: Du legst Dateien in einen functions/-Ordner, und das Routing ergibt sich aus der Ordnerstruktur.

functions/
├── api/
│   ├── users.ts          → /api/users
│   └── posts/
│       └── [id].ts       → /api/posts/:id (dynamisch)
└── _middleware.ts        → läuft vor allen Routen

Das war bequem, aber auch ein Vendor-Lock-in. Kein anderer Anbieter nutzt dieses System. Workers verwendet stattdessen Standard-JavaScript-Exports.

Der Kompilierungsschritt

Cloudflare bietet ein Tool, um bestehende Functions zu migrieren:

npx wrangler pages functions build \
  --outdir=./dist/worker/ \
  --compatibility-date=2025-01-01

Dieser Befehl nimmt alle Dateien aus functions/, verarbeitet die Routing-Logik und erstellt eine einzelne Worker-Datei. Die wrangler.toml zeigt dann auf diese Datei:

name = "meine-app"
compatibility_date = "2025-01-01"
main = "./dist/worker/index.js"

[assets]
directory = "./dist/client/"

Von PagesFunction zu ExportedHandler

Wenn du Functions manuell migrieren willst (oder musst), hier der Unterschied im Code:

Pages-Syntax – verwendet spezielle Typen und benannte Exports:

// functions/api/users.ts

// Der Typ PagesFunction ist Pages-spezifisch
// env, request und andere Werte kommen als Objekt-Properties
export const onRequestGet: PagesFunction<Env> = async ({ env, request }) => {
  // Datenbankabfrage mit D1
  const users = await env.DB.prepare('SELECT * FROM users').all();
  return Response.json(users.results);
};

// Separater Export für POST-Requests
export const onRequestPost: PagesFunction<Env> = async ({ env, request }) => {
  const body = await request.json();
  // User erstellen...
  return Response.json({ success: true });
};

Workers-Syntax – Standard-JavaScript mit einem default Export:

// src/worker.ts

// Ein einziger default Export mit einer fetch-Methode
export default {
  async fetch(
    request: Request,    // Der eingehende Request
    env: Env,            // Deine Bindings (DB, KV, etc.)
    ctx: ExecutionContext // Für Hintergrund-Tasks
  ): Promise<Response> {
    
    const url = new URL(request.url);
    
    // Routing musst du selbst machen
    if (url.pathname === '/api/users') {
      
      if (request.method === 'GET') {
        const users = await env.DB.prepare('SELECT * FROM users').all();
        return Response.json(users.results);
      }
      
      if (request.method === 'POST') {
        const body = await request.json();
        // User erstellen...
        return Response.json({ success: true });
      }
      
      // Method not allowed
      return new Response('Method not allowed', { status: 405 });
    }
    
    // Keine passende Route gefunden
    // Assets werden automatisch geprüft, wenn dieser Code 404 zurückgibt
    return new Response('Not Found', { status: 404 });
  }
} satisfies ExportedHandler<Env>;

Das ist mehr Code, aber auch flexibler. Du hast volle Kontrolle über das Routing.

Routing-Bibliotheken nutzen

Für komplexere APIs ist manuelles Routing unpraktisch. Hier kommen leichtgewichtige Router ins Spiel. Hono ist aktuell die beste Wahl für Cloudflare Workers:

import { Hono } from 'hono';

// Typisierung für Cloudflare-Bindings
type Bindings = {
  DB: D1Database;
  CACHE: KVNamespace;
};

const app = new Hono<{ Bindings: Bindings }>();

// Middleware für alle Routen
app.use('*', async (c, next) => {
  console.log(`${c.req.method} ${c.req.url}`);
  await next();
});

// GET /api/users
app.get('/api/users', async (c) => {
  const users = await c.env.DB.prepare('SELECT * FROM users').all();
  return c.json(users.results);
});

// POST /api/users
app.post('/api/users', async (c) => {
  const body = await c.req.json();
  
  await c.env.DB.prepare(
    'INSERT INTO users (name, email) VALUES (?, ?)'
  ).bind(body.name, body.email).run();
  
  return c.json({ success: true }, 201);
});

// GET /api/users/:id - dynamische Route
app.get('/api/users/:id', async (c) => {
  const id = c.req.param('id');
  const user = await c.env.DB.prepare(
    'SELECT * FROM users WHERE id = ?'
  ).bind(id).first();
  
  if (!user) {
    return c.json({ error: 'User not found' }, 404);
  }
  
  return c.json(user);
});

// Hono-App als Worker exportieren
export default app;

Hono ist klein (unter 20KB), schnell und fühlt sich an wie Express – nur moderner. Die Lernkurve ist minimal.

Bindings migrieren

Bindings sind die Verbindungen zu Cloudflares Diensten: Datenbanken, Key-Value-Stores, Object Storage. Bei Pages wurden diese im Dashboard konfiguriert. Bei Workers stehen sie in der wrangler.toml.

Environment Variables

Nicht-geheime Konfigurationswerte gehören in die TOML-Datei:

[vars]
API_VERSION = "v2"
ENVIRONMENT = "production"
MAX_UPLOAD_SIZE = "10485760"  # 10 MB in Bytes

Diese Werte landen in env.API_VERSION, env.ENVIRONMENT etc. Sie sind im Code und in Git sichtbar – also nur für Werte, die nicht geheim sind.

Secrets

Für API-Keys, Datenbank-Passwörter und andere sensible Daten:

# Interaktiv eingeben (wird nicht in der Shell-History gespeichert)
npx wrangler secret put DATABASE_URL

# Oder aus einer Datei lesen
echo "super-secret-key" | npx wrangler secret put AUTH_SECRET

Secrets werden verschlüsselt gespeichert und sind nur zur Laufzeit verfügbar. Sie erscheinen nie in Logs oder in der wrangler.toml.

KV Namespaces

KV (Key-Value) ist Cloudflares schneller, global verteilter Speicher für kleine Datenmengen:

[[kv_namespaces]]
binding = "CACHE"           # So heißt es in deinem Code: env.CACHE
id = "abc123def456"         # Die ID aus dem Dashboard

[[kv_namespaces]]
binding = "SESSIONS"
id = "789ghi012jkl"

Im Code:

// Wert speichern (mit 1 Stunde TTL)
await env.CACHE.put('user:123', JSON.stringify(userData), {
  expirationTtl: 3600
});

// Wert lesen
const cached = await env.CACHE.get('user:123', 'json');

D1 Databases

D1 ist Cloudflares SQLite-basierte Datenbank:

[[d1_databases]]
binding = "DB"
database_name = "production-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Im Code:

// Prepared Statements (sicher gegen SQL-Injection)
const users = await env.DB.prepare(
  'SELECT * FROM users WHERE status = ?'
).bind('active').all();

// Einzelnes Ergebnis
const user = await env.DB.prepare(
  'SELECT * FROM users WHERE id = ?'
).bind(userId).first();

R2 Buckets

R2 ist Object Storage (wie S3, aber ohne Egress-Kosten):

[[r2_buckets]]
binding = "STORAGE"
bucket_name = "my-uploads"

Im Code:

// Datei hochladen
await env.STORAGE.put('images/photo.jpg', imageData, {
  httpMetadata: { contentType: 'image/jpeg' }
});

// Datei lesen
const object = await env.STORAGE.get('images/photo.jpg');
if (object) {
  return new Response(object.body, {
    headers: { 'Content-Type': object.httpMetadata.contentType }
  });
}

Durable Objects (neu für Pages-Migranten)

Durable Objects sind das mächtigste Feature, das Pages-Nutzer bisher nicht hatten. Sie ermöglichen persistenten Zustand und Koordination am Edge:

[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"

[[migrations]]
tag = "v1"
new_classes = ["Counter"]

Ein einfaches Beispiel – ein Zähler, der Requests zählt:

// Die Durable Object Klasse
export class Counter {
  private state: DurableObjectState;
  private count: number = 0;
  
  constructor(state: DurableObjectState) {
    this.state = state;
    // Zustand aus dem Storage laden
    this.state.blockConcurrencyWhile(async () => {
      this.count = await this.state.storage.get('count') || 0;
    });
  }
  
  async fetch(request: Request) {
    this.count++;
    await this.state.storage.put('count', this.count);
    return new Response(`Count: ${this.count}`);
  }
}

// Im Worker nutzen
export default {
  async fetch(request: Request, env: Env) {
    // Durable Object anhand einer ID abrufen
    const id = env.COUNTER.idFromName('global');
    const counter = env.COUNTER.get(id);
    
    // Request an das Durable Object weiterleiten
    return counter.fetch(request);
  }
};

Durable Objects garantieren, dass immer nur eine Instanz gleichzeitig läuft. Perfekt für Koordination, WebSocket-Verbindungen oder Echtzeit-Features.

Framework-spezifische Migration

Astro

Astro ist besonders gut für Cloudflare geeignet. Die Konfiguration:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  output: 'server',  // Oder 'hybrid' für teilweise statisch
  adapter: cloudflare({
    // Cloudflares Bildoptimierung nutzen
    imageService: 'cloudflare',
    // Lokale Entwicklung mit echten Bindings
    platformProxy: {
      enabled: true,
      configPath: 'wrangler.toml',
    },
  }),
});

Die wrangler.toml für Astro:

name = "astro-app"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]  # Für Node.js-APIs
main = "./dist/_worker.js"

[assets]
directory = "./dist/client/"

# Optional: Bessere Logs
[observability]
enabled = true

Das nodejs_compat-Flag ist wichtig: Astro und viele npm-Pakete nutzen Node.js-APIs wie Buffer oder crypto. Ohne dieses Flag würden sie nicht funktionieren.

SvelteKit

// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';

export default {
  kit: {
    adapter: adapter({
      routes: {
        include: ['/*'],
        exclude: ['<all>'],  // Statische Assets ausschließen
      },
    }),
  },
};

Next.js

Next.js auf Cloudflare benötigt einen zusätzlichen Build-Schritt:

npm install @cloudflare/next-on-pages
# wrangler.toml
name = "next-app"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
main = ".worker-next/index.js"

[assets]
directory = ".worker-next/assets"
binding = "ASSETS"

Der Build-Befehl:

npx @cloudflare/next-on-pages
npx wrangler deploy

Lokale Entwicklung

Der Dev-Server

Der lokale Entwicklungsserver hat sich geändert:

# Alt (Pages)
npx wrangler pages dev ./dist --port 8788

# Neu (Workers)
npx wrangler dev --port 8787

Wichtiger als der Port: Der neue Dev-Server kann mehr. Er simuliert alle Bindings lokal – KV, D1, R2, sogar Durable Objects.

Remote Mode

Für Tests mit echten Daten:

npx wrangler dev --remote

Dieser Modus verbindet sich mit deinen echten Cloudflare-Ressourcen. Nützlich für Debugging, aber Vorsicht: Änderungen an der Datenbank sind real.

Cron Triggers

Ein Feature, das Pages nie hatte: zeitgesteuerte Jobs. Mit Workers kannst du Code nach einem Zeitplan ausführen:

[triggers]
crons = ["0 * * * *", "0 0 * * *"]

Die Syntax ist Standard-Cron:

  • 0 * * * * = jede Stunde zur vollen Stunde
  • 0 0 * * * = täglich um Mitternacht
  • */15 * * * * = alle 15 Minuten

Im Code:

export default {
  // HTTP-Requests
  async fetch(request: Request, env: Env) {
    return new Response('Hello');
  },
  
  // Cron-Jobs
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    // Welcher Cron hat ausgelöst?
    switch (event.cron) {
      case '0 * * * *':
        // Stündlicher Job
        await refreshCache(env);
        break;
        
      case '0 0 * * *':
        // Täglicher Job
        await generateDailyReport(env);
        // Lange Tasks in den Hintergrund
        ctx.waitUntil(sendReportEmail(env));
        break;
    }
  },
};

ctx.waitUntil() ist wichtig für längere Tasks. Der Cron-Handler muss schnell zurückkehren, aber waitUntil hält den Worker am Leben, bis die Hintergrund-Aufgabe fertig ist.

Observability

Workers bietet deutlich bessere Einblicke als Pages:

Logs aktivieren

[observability]
enabled = true
head_sampling_rate = 1  # 1 = 100%, 0.1 = 10% der Requests

Mit head_sampling_rate kontrollierst du, wie viele Requests geloggt werden. Bei hohem Traffic spart 0.1 (10%) Kosten, ohne den Überblick zu verlieren.

Logs extern speichern

Für längere Aufbewahrung oder Integration mit anderen Tools:

npx wrangler logpush create \
  --destination-conf "s3://my-bucket?region=eu-west-1" \
  --dataset workers_trace_events

Unterstützte Ziele: S3, R2, Datadog, Splunk, und mehr.

Tail Workers

Real-time Streaming zu einem anderen Worker – für Custom-Logging oder Alerting:

[[tail_consumers]]
service = "log-collector"

Der log-collector-Worker empfängt dann alle Logs und kann sie verarbeiten, filtern oder weiterleiten.

Checkliste für die Migration

Eine praktische Liste zum Abhaken:

## Vorbereitung
- [ ] Aktuelle wrangler-Version installieren: npm install -g wrangler
- [ ] Bestehendes Projekt lokal testen

## Konfiguration
- [ ] wrangler.toml erstellen
- [ ] pages_build_output_dir → [assets].directory
- [ ] compatibility_date setzen

## Code-Migration
- [ ] Functions zu Worker-Syntax konvertieren (falls vorhanden)
- [ ] Routing-Logik implementieren oder Hono einbinden
- [ ] TypeScript-Typen anpassen (PagesFunction → ExportedHandler)

## Bindings
- [ ] KV Namespaces in wrangler.toml übertragen
- [ ] D1 Databases konfigurieren
- [ ] R2 Buckets konfigurieren
- [ ] Secrets via `wrangler secret put` setzen

## Framework
- [ ] Adapter aktualisieren (Astro, SvelteKit, Next.js)
- [ ] Build-Skripte anpassen

## Test
- [ ] Lokale Entwicklung: wrangler dev
- [ ] Alle Routen testen
- [ ] Bindings testen

## Deployment
- [ ] CI/CD-Pipeline anpassen
- [ ] Deployment testen: wrangler deploy
- [ ] DNS/Custom Domain konfigurieren
- [ ] Altes Pages-Projekt löschen: wrangler pages project delete

Empfehlung

Für bestehende Pages-Projekte ohne Bedarf an neuen Features: Abwarten ist okay. Cloudflare wird automatisch migrieren, sobald das risikofrei möglich ist. Dein Projekt läuft weiter.

Für neue Projekte: Direkt mit Workers starten. Die Dokumentation ist aktuell, das Tooling besser, und alle Features sind von Anfang an verfügbar.

Für Projekte mit CI/CD-Anforderungen: Die Migration lohnt sich. wrangler deploy integriert sich sauberer in GitHub Actions als die Pages-spezifischen Befehle. Details dazu im Artikel CI/CD für Cloudflare Pages & Workers.

Für Projekte, die Echtzeit-Features, Koordination oder persistenten Zustand brauchen: Migration ist notwendig. Durable Objects gibt es nur mit Workers.

Weiterführende Ressourcen