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:
| Aspekt | Pages | Workers |
|---|---|---|
| Runtime | V8 Isolates | V8 Isolates |
| Entry Point | _worker.js oder functions/ | main in wrangler.toml |
| Asset Handling | Implizit (automatisch) | Explizit (muss konfiguriert werden) |
| Build | Cloudflare-seitig oder lokal | Immer lokal |
| Bindings | KV, D1, R2 | KV, D1, R2, Durable Objects, Queues, Cron |
| Observability | Basis-Logs | Logs, 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?
| Wert | Was passiert |
|---|---|
auto-trailing-slash | /about wird zu /about/ (Standard) |
force-trailing-slash | Redirect zu URL mit Slash am Ende |
drop-trailing-slash | /about/ wird zu /about |
none | Keine 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?
| Wert | Verhalten |
|---|---|
none | 404-Fehler zurückgeben (Standard) |
single-page-application | index.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 Stunde0 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.