Blurred background close-up of a hand holding an npm sticker, ideal for web development themes.

Real-Time Forms in Next.js 15 with PartyKit

· 4 min read

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.

Advertisement

Share this page

Article by Marcus Rodriguez

Full-stack developer specializing in Next.js and modern React patterns. Creator of several open-source React libraries with over 50k downloads.

Related Content

Continue learning with these related articles