Real-time collaborative forms represent a significant UX challenge in modern web applications. Whether building survey tools, CRM systems, or administrative dashboards, developers need solutions that handle concurrent edits without data loss while maintaining acceptable performance. PartyKit provides a WebSocket infrastructure that integrates cleanly with Next.js 15's server components and streaming capabilities.
Understanding PartyKit's Architecture
PartyKit operates as a hosted WebSocket server with built-in room management and connection handling. Unlike traditional WebSocket implementations that require custom infrastructure, PartyKit provides a serverless approach where each "party" (room) maintains its own isolated state and connection pool.
The key architectural benefit: PartyKit rooms are stateful and durable. When a client connects, the room's state persists across connections, making it ideal for collaborative features where multiple users interact with shared data. The server-side room logic runs on Cloudflare's edge network, reducing latency for global users.
For form collaboration specifically, PartyKit handles the complexity of operational transformation (OT) or conflict-free replicated data types (CRDTs) through its message broadcasting system. Developers can implement their preferred conflict resolution strategy while PartyKit manages the transport layer.
Setting Up the Next.js 15 Project Structure
Next.js 15's App Router provides an optimal foundation for real-time features through its support for streaming and server components. The project structure should separate concerns between the PartyKit server logic and Next.js client components.
1// app/collaborative-form/page.tsx
2import { CollaborativeForm } from '@/components/CollaborativeForm';
3
4export default function FormPage({
5 params
6}: {
7 params: { formId: string }
8}) {
9 return (
10 <div className="max-w-4xl mx-auto p-6">
11 <CollaborativeForm formId={params.formId} />
12 </div>
13 );
14}The PartyKit server runs separately from the Next.js application but can be deployed alongside it. This separation allows the WebSocket server to scale independently from the HTTP server handling page requests.
1// partykit/server.ts
2import type * as Party from "partykit/server";
3
4interface FormState {
5 fields: Record<string, string>;
6 cursors: Record<string, { field: string; position: number }>;
7 activeUsers: Record<string, { name: string; color: string }>;
8}
9
10export default class FormServer implements Party.Server {
11 state: FormState;
12
13 constructor(readonly room: Party.Room) {
14 this.state = {
15 fields: {},
16 cursors: {},
17 activeUsers: {}
18 };
19 }
20
21 async onConnect(conn: Party.Connection) {
22 // Send current state to new connection
23 conn.send(JSON.stringify({
24 type: 'sync',
25 state: this.state
26 }));
27
28 // Broadcast new user joined
29 this.room.broadcast(
30 JSON.stringify({
31 type: 'user-joined',
32 userId: conn.id
33 }),
34 [conn.id] // exclude sender
35 );
36 }
37
38 async onMessage(message: string, sender: Party.Connection) {
39 const data = JSON.parse(message);
40
41 switch (data.type) {
42 case 'field-update':
43 this.state.fields[data.fieldId] = data.value;
44 // Broadcast to all except sender
45 this.room.broadcast(message, [sender.id]);
46 break;
47
48 case 'cursor-move':
49 this.state.cursors[sender.id] = {
50 field: data.fieldId,
51 position: data.position
52 };
53 this.room.broadcast(message, [sender.id]);
54 break;
55
56 case 'user-info':
57 this.state.activeUsers[sender.id] = {
58 name: data.name,
59 color: data.color
60 };
61 this.room.broadcast(message);
62 break;
63 }
64 }
65
66 async onClose(conn: Party.Connection) {
67 delete this.state.cursors[conn.id];
68 delete this.state.activeUsers[conn.id];
69
70 this.room.broadcast(JSON.stringify({
71 type: 'user-left',
72 userId: conn.id
73 }));
74 }
75}Implementing the Client-Side Form Component
The client component manages WebSocket connections, local state, and optimistic updates. Next.js 15's use client directive clearly separates this interactive component from server components.
Advertisement
1// components/CollaborativeForm.tsx
2'use client';
3
4import { useEffect, useRef, useState } from 'react';
5import usePartySocket from 'partysocket/react';
6
7interface FormField {
8 id: string;
9 label: string;
10 type: 'text' | 'email' | 'textarea';
11}
12
13const FORM_FIELDS: FormField[] = [
14 { id: 'name', label: 'Full Name', type: 'text' },
15 { id: 'email', label: 'Email Address', type: 'email' },
16 { id: 'message', label: 'Message', type: 'textarea' }
17];
18
19export function CollaborativeForm({ formId }: { formId: string }) {
20 const [fields, setFields] = useState<Record<string, string>>({});
21 const [activeUsers, setActiveUsers] = useState<Record<string, any>>({});
22 const [cursors, setCursors] = useState<Record<string, any>>({});
23 const inputRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement>>({});
24
25 const socket = usePartySocket({
26 host: process.env.NEXT_PUBLIC_PARTYKIT_HOST,
27 room: formId,
28 onMessage(evt) {
29 const data = JSON.parse(evt.data);
30
31 switch (data.type) {
32 case 'sync':
33 setFields(data.state.fields);
34 setActiveUsers(data.state.activeUsers);
35 setCursors(data.state.cursors);
36 break;
37
38 case 'field-update':
39 setFields(prev => ({
40 ...prev,
41 [data.fieldId]: data.value
42 }));
43 break;
44
45 case 'cursor-move':
46 setCursors(prev => ({
47 ...prev,
48 [data.userId]: { field: data.fieldId, position: data.position }
49 }));
50 break;
51
52 case 'user-joined':
53 case 'user-left':
54 // Handle user presence updates
55 break;
56 }
57 }
58 });
59
60 const handleFieldChange = (fieldId: string, value: string) => {
61 // Optimistic update
62 setFields(prev => ({ ...prev, [fieldId]: value }));
63
64 // Broadcast change
65 socket.send(JSON.stringify({
66 type: 'field-update',
67 fieldId,
68 value
69 }));
70 };
71
72 const handleCursorMove = (fieldId: string, position: number) => {
73 socket.send(JSON.stringify({
74 type: 'cursor-move',
75 fieldId,
76 position
77 }));
78 };
79
80 useEffect(() => {
81 // Register user with random color
82 if (socket) {
83 socket.send(JSON.stringify({
84 type: 'user-info',
85 name: `User ${Math.floor(Math.random() * 1000)}`,
86 color: `#${Math.floor(Math.random()*16777215).toString(16)}`
87 }));
88 }
89 }, [socket]);
90
91 return (
92 <div className="space-y-6">
93 <div className="flex gap-2 mb-4">
94 {Object.entries(activeUsers).map(([id, user]) => (
95 <div
96 key={id}
97 className="px-3 py-1 rounded-full text-sm"
98 style={{ backgroundColor: user.color + '20', color: user.color }}
99 >
100 {user.name}
101 </div>
102 ))}
103 </div>
104
105 {FORM_FIELDS.map(field => (
106 <div key={field.id} className="relative">
107 <label className="block text-sm font-medium mb-2">
108 {field.label}
109 </label>
110 {field.type === 'textarea' ? (
111 <textarea
112 ref={el => { if (el) inputRefs.current[field.id] = el; }}
113 value={fields[field.id] || ''}
114 onChange={e => handleFieldChange(field.id, e.target.value)}
115 onSelect={e => {
116 const target = e.target as HTMLTextAreaElement;
117 handleCursorMove(field.id, target.selectionStart);
118 }}
119 className="w-full p-3 border rounded-lg"
120 rows={4}
121 />
122 ) : (
123 <input
124 ref={el => { if (el) inputRefs.current[field.id] = el; }}
125 type={field.type}
126 value={fields[field.id] || ''}
127 onChange={e => handleFieldChange(field.id, e.target.value)}
128 onSelect={e => {
129 const target = e.target as HTMLInputElement;
130 handleCursorMove(field.id, target.selectionStart);
131 }}
132 className="w-full p-3 border rounded-lg"
133 />
134 )}
135 {/* Render other users' cursors */}
136 {Object.entries(cursors)
137 .filter(([_, cursor]) => cursor.field === field.id)
138 .map(([userId, cursor]) => (
139 <div
140 key={userId}
141 className="absolute h-6 w-0.5"
142 style={{
143 backgroundColor: activeUsers[userId]?.color,
144 left: `${cursor.position * 8}px`,
145 top: '50%',
146 animation: 'blink 1s infinite'
147 }}
148 />
149 ))}
150 </div>
151 ))}
152 </div>
153 );
154}Handling Conflict Resolution and Data Persistence
Real-time collaboration introduces race conditions when multiple users edit the same field simultaneously. The implementation above uses a last-write-wins strategy, which works for many use cases but may cause data loss in high-conflict scenarios.
For production applications, consider implementing operational transformation or using a CRDT library like Yjs. Yjs integrates well with PartyKit and provides automatic conflict resolution:
1// Enhanced server with Yjs
2import * as Y from 'yjs';
3import type * as Party from "partykit/server";
4
5export default class FormServerWithYjs implements Party.Server {
6 ydoc: Y.Doc;
7 yform: Y.Map<string>;
8
9 constructor(readonly room: Party.Room) {
10 this.ydoc = new Y.Doc();
11 this.yform = this.ydoc.getMap('form');
12 }
13
14 async onMessage(message: string | ArrayBuffer, sender: Party.Connection) {
15 if (message instanceof ArrayBuffer) {
16 // Yjs sync message
17 Y.applyUpdate(this.ydoc, new Uint8Array(message));
18 // Broadcast update to other clients
19 this.room.broadcast(message, [sender.id]);
20 }
21 }
22}Data persistence requires integration with a database. PartyKit rooms can connect to external storage through their onConnect and onClose hooks:
1async onConnect(conn: Party.Connection) {
2 // Load initial state from database
3 const savedState = await this.room.storage.get('formState');
4 if (savedState) {
5 this.state = savedState as FormState;
6 }
7
8 conn.send(JSON.stringify({ type: 'sync', state: this.state }));
9}
10
11async onMessage(message: string, sender: Party.Connection) {
12 // ... handle message
13
14 // Persist state changes
15 await this.room.storage.put('formState', this.state);
16}Performance Considerations and Scaling
WebSocket connections consume server resources differently than HTTP requests. Each active connection maintains an open socket, which limits the number of concurrent users per PartyKit room. For most form applications, this limit (around 1000-2000 concurrent users per room) provides adequate capacity.
Throttling message broadcasts prevents performance degradation during rapid updates. Debouncing field changes on the client side reduces network traffic:
1const debouncedUpdate = useMemo(
2 () => debounce((fieldId: string, value: string) => {
3 socket.send(JSON.stringify({
4 type: 'field-update',
5 fieldId,
6 value
7 }));
8 }, 150),
9 [socket]
10);For applications requiring presence indicators across thousands of forms, implement room sharding or use PartyKit's built-in connection limits to prevent resource exhaustion. Monitor connection counts and implement graceful degradation when approaching capacity limits.
The combination of Next.js 15's streaming capabilities and PartyKit's edge deployment creates a responsive collaborative experience. Server components can pre-render static form structure while client components handle real-time updates, minimizing initial bundle size and improving time-to-interactive metrics.






