Migrate to PartyServer
Timeline
| Date | What happens |
|---|---|
| 9 October 2026 | No new projects or preview environments. Existing projects can still be redeployed. |
| 12 October 2026 | Projects with no activity in the last 3 months are removed. |
| 16 October 2026 | Deploys are turned off. Deadline to export your data, or to ask us for help. |
| 23 October 2026 | The hosted platform shuts down. Remaining projects and their room data are deleted. |
| 23 October 2026 | npx partykit deploy to your own Cloudflare account stops working. Workers already in your account keep running. |
Running something people depend on, or can’t make these dates? Tell us on Discord before 16 October and we’ll keep your project running while we help you move it.
PartyServer is the successor to PartyKit. It keeps the same model (rooms, connections, onConnect / onMessage, hibernation, broadcasting) but runs as a plain Cloudflare Worker with Durable Objects in your own Cloudflare account. Nothing sits in between you and Cloudflare: you deploy with wrangler, configure with wrangler.jsonc, and pay Cloudflare directly. The Workers free plan includes SQLite-backed Durable Objects.
Your clients keep working mostly unchanged: partysocket talks to PartyServer too.
What changes
| PartyKit | PartyServer |
|---|---|
partykit.json | wrangler.jsonc |
npx partykit dev / deploy | npx wrangler dev / deploy |
class Server implements Party.Server | class MyServer extends Server (from partyserver) |
constructor(readonly room: Party.Room) | no constructor needed, use this |
this.room.id | this.name |
this.room.storage | this.ctx.storage |
this.room.env | this.env |
this.room.broadcast(msg, without) | this.broadcast(msg, without) |
this.room.getConnection(id) / getConnections(tag) | this.getConnection(id) / this.getConnections(tag) |
onMessage(message, sender) | onMessage(connection, message) (arguments are swapped) |
onConnect(connection, ctx), onClose, onError, onRequest, onAlarm, onStart, getConnectionTags | same names |
static options = { hibernate: true } | same |
static onBeforeConnect / onBeforeRequest | options to routePartykitRequest() |
static onFetch | your Worker’s fetch handler |
static onCron + crons in partykit.json | triggers.crons in wrangler.jsonc + a scheduled handler |
parties in partykit.json | one Durable Object binding per server class |
this.room.context.parties.other.get(id) | getServerByName(this.env.Other, id) |
serve (static assets) | assets in wrangler.jsonc |
vars / npx partykit env | vars in wrangler.jsonc / npx wrangler secret put |
room.context.ai, vectorize, bindings | regular Workers bindings |
y-partykit | y-partyserver |
https://<project>.<user>.partykit.dev | https://<name>.<subdomain>.workers.dev or your own domain |
Step by step
1. Install
npm install partyservernpm install -D wranglerRemove partykit from your dependencies once you’re done.
2. Port the server
Before (PartyKit):
import type * as Party from "partykit/server";
export default class Chat implements Party.Server { constructor(readonly room: Party.Room) {}
static options = { hibernate: true };
onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) { conn.send(`welcome to ${this.room.id}`); }
async onMessage(message: string, sender: Party.Connection) { await this.room.storage.put("last", message); this.room.broadcast(message, [sender.id]); }}After (PartyServer):
import { routePartykitRequest, Server } from "partyserver";import type { Connection } from "partyserver";
type Env = { Main: DurableObjectNamespace<Chat> };
export class Chat extends Server<Env> { static options = { hibernate: true };
onConnect(conn: Connection) { conn.send(`welcome to ${this.name}`); }
async onMessage(sender: Connection, message: string) { await this.ctx.storage.put("last", message); this.broadcast(message, [sender.id]); }}
export default { async fetch(request: Request, env: Env): Promise<Response> { return ( (await routePartykitRequest(request, env)) || new Response("Not Found", { status: 404 }) ); }} satisfies ExportedHandler<Env>;routePartykitRequest serves /parties/:server/:room, where :server is the kebab-cased name of a Durable Object binding. Naming the binding Main means existing clients that connect to the default main party keep working without changes.
3. Replace partykit.json with wrangler.jsonc
{ "name": "my-chat", "main": "src/server.ts", "compatibility_date": "2025-06-01", "compatibility_flags": ["nodejs_compat"], "durable_objects": { "bindings": [{ "name": "Main", "class_name": "Chat" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Chat"] }],
// if you used "serve" "assets": { "directory": "./public" },
// if you used "vars"; use `wrangler secret put` for secrets "vars": { "SOME_SETTING": "value" }
// if you used "crons" // "triggers": { "crons": ["*/5 * * * *"] }}Unlike PartyKit, wrangler doesn’t create Durable Object migrations for you. Add a new entry to migrations whenever you add, rename or remove a server class.
4. Static onBeforeConnect / onBeforeRequest / onFetch
export default { async fetch(request: Request, env: Env) { return ( (await routePartykitRequest(request, env, { onBeforeConnect: async (req) => { const token = new URL(req.url).searchParams.get("token"); if (!(await isValid(token))) { return new Response("Unauthorized", { status: 401 }); } } })) || // whatever your static onFetch used to do new Response("Not Found", { status: 404 }) ); }} satisfies ExportedHandler<Env>;5. Update your clients
Point host at your new deployment:
const socket = new PartySocket({ host: "my-chat.<your-subdomain>.workers.dev", room: "my-room"});If you had PARTYKIT_HOST baked into your frontend, replace it with the new host. If your clients connect to the old /party/:room URLs (very old partysocket versions), either upgrade partysocket or rewrite /party/ to /parties/main/ in your fetch handler.
6. Run and deploy
npx wrangler devnpx wrangler loginnpx wrangler deployMoving your data
Room storage (this.room.storage) on the managed platform lives in Durable Objects in PartyKit’s Cloudflare account. It can’t be transferred to your account automatically, and it will be deleted when the platform shuts down. If you need it, export it while you can still redeploy your PartyKit project:
You need three things: an export endpoint on your old PartyKit server, an import endpoint on your new PartyServer server, and a script that copies each room from one to the other.
1. Add an export endpoint to your PartyKit server
Add this to your PartyKit server class (merge it into your existing onRequest if you have one), then set a secret and redeploy:
async onRequest(req: Party.Request) { if (req.headers.get("Authorization") !== `Bearer ${this.room.env.EXPORT_SECRET}`) { return new Response("Unauthorized", { status: 401 }); } const data = await this.room.storage.list(); return Response.json(Object.fromEntries(data));}npx partykit env add EXPORT_SECRETnpx partykit deployCheck it works: curl -H "Authorization: Bearer $EXPORT_SECRET" https://<project>.<user>.partykit.dev/parties/main/<room-id>
2. Add an import endpoint to your PartyServer server
async onRequest(req: Request) { if (req.method === "POST" && new URL(req.url).pathname.endsWith("/import")) { if (req.headers.get("Authorization") !== `Bearer ${this.env.IMPORT_SECRET}`) { return new Response("Unauthorized", { status: 401 }); } const entries = Object.entries(await req.json<Record<string, unknown>>()); // put() accepts at most 128 keys at a time for (let i = 0; i < entries.length; i += 128) { await this.ctx.storage.put(Object.fromEntries(entries.slice(i, i + 128))); } return Response.json({ imported: entries.length }); } // ...the rest of your onRequest return new Response("Not Found", { status: 404 });}npx wrangler secret put IMPORT_SECRETnpx wrangler deploy3. Copy your rooms
Save this as migrate-rooms.mjs (Node 18+, no dependencies) and put the room ids you want to keep in rooms.txt, one per line. You can’t list rooms on the managed platform yourself, so take the ids from your own database, logs or URLs.
// migrate-rooms.mjs: copies room storage from PartyKit to PartyServer.// Every room is also saved to ./export/<room>.json as a backup.// Leave out NEW_HOST to only export.import fs from "node:fs";
const { OLD_HOST, NEW_HOST, EXPORT_SECRET, IMPORT_SECRET } = process.env;const PARTY = process.env.PARTY ?? "main";const roomsFile = process.argv[2];if (!OLD_HOST || !EXPORT_SECRET || !roomsFile) { console.error( "Usage: OLD_HOST=... EXPORT_SECRET=... [NEW_HOST=... IMPORT_SECRET=...] node migrate-rooms.mjs rooms.txt" ); process.exit(1);}const scheme = (host) => (/^(localhost|127\.)/.test(host) ? "http" : "https");const rooms = fs .readFileSync(roomsFile, "utf8") .split("\n") .map((l) => l.trim()) .filter(Boolean);fs.mkdirSync("export", { recursive: true });
let ok = 0;for (const room of rooms) { try { const res = await fetch( `${scheme(OLD_HOST)}://${OLD_HOST}/parties/${PARTY}/${encodeURIComponent(room)}`, { headers: { Authorization: `Bearer ${EXPORT_SECRET}` } } ); if (!res.ok) throw new Error(`export: ${res.status} ${await res.text()}`); const data = await res.text(); fs.writeFileSync(`export/${encodeURIComponent(room)}.json`, data);
if (NEW_HOST) { const imp = await fetch( `${scheme(NEW_HOST)}://${NEW_HOST}/parties/${PARTY}/${encodeURIComponent(room)}/import`, { method: "POST", headers: { Authorization: `Bearer ${IMPORT_SECRET}`, "Content-Type": "application/json" }, body: data } ); if (!imp.ok) throw new Error(`import: ${imp.status} ${await imp.text()}`); } ok++; console.log(`✓ ${room}`); } catch (e) { console.error(`✗ ${room}: ${e.message}`); }}console.log( `${ok}/${rooms.length} rooms ${NEW_HOST ? "migrated" : "exported"}`);OLD_HOST=<project>.<user>.partykit.dev EXPORT_SECRET=... \NEW_HOST=<name>.<subdomain>.workers.dev IMPORT_SECRET=... \node migrate-rooms.mjs rooms.txtUse PARTY=<name> to copy rooms from one of your other parties (the matching PartyServer binding must kebab-case to the same name). Re-running is safe; it overwrites the same keys.
Things to know:
- Values go through JSON, so
Dates become strings andMaps,Sets and binary data don’t survive as-is. If you stored those, convert them in the export endpoint and back in the import endpoint. - PartyServer keeps a
__ps_namekey in each room’s storage. Don’t use that key name yourself. - Remove both endpoints once you’re done.
If you use y-partykit with persistence, export the document from onRequest with Y.encodeStateAsUpdate(doc) and apply it with Y.applyUpdate in your new y-partyserver room.
Already deploying to your own Cloudflare account?
If you used cloud-prem (CLOUDFLARE_ACCOUNT_ID=... npx partykit deploy --domain ...), your Worker and its data already live in your account and keep running after the PartyKit platform shuts down. Only npx partykit deploy stops working, because it goes through the PartyKit API.
You have two options:
-
Start fresh with a new Worker following the steps above, and move data over with an export endpoint.
-
Keep your existing Worker and its data (advanced). PartyKit deployed your Worker with the Durable Object class
PartyKitDurablebound asPARTYKIT_DURABLE(plus one class per entry inparties). Rooms were addressed withidFromName(roomId), which is also what PartyServer does. If you deploy a PartyServer Worker under the same Worker name, export your main server class asPartyKitDurable, and bind it under the same name, existing rooms keep their storage. Leavemigrationsout ofwrangler.jsoncfor classes that already exist, and don’t switch them to SQLite storage (new_sqlite_classes), since existing classes can’t change storage backend. Because the binding isn’t calledMain, route requests yourself instead of relying onroutePartykitRequest’s name matching:import { getServerByName } from "partyserver";export default {async fetch(request: Request, env: Env) {const [, prefix, party, room] = new URL(request.url).pathname.split("/");if (prefix === "parties" && party === "main" && room) {const stub = await getServerByName(env.PARTYKIT_DURABLE, room);return stub.fetch(request);}return new Response("Not Found", { status: 404 });}} satisfies ExportedHandler<Env>;Try it on a copy first. Deploy under a new Worker name, check behaviour, then deploy over the original.
Getting help
Ask on our Discord, or open an issue on cloudflare/partykit.