Skip to content

Next.js

WriteTrack uses browser APIs and must run client-side only. Here’s how to integrate it with Next.js.

Add the 'use client' directive to components using WriteTrack:

'use client';
import { useRef, useEffect } from 'react';
import { WriteTrack } from 'writetrack';
export function ResponseForm() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const trackerRef = useRef<WriteTrack | null>(null);
useEffect(() => {
if (textareaRef.current) {
trackerRef.current = new WriteTrack({ target: textareaRef.current });
trackerRef.current.start();
}
return () => {
trackerRef.current?.stop();
};
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (trackerRef.current) {
const data = trackerRef.current.getData();
const events = trackerRef.current.getRawEvents();
console.log(`Captured ${events.length} events`, data.quality);
}
};
return (
<form onSubmit={handleSubmit}>
<textarea ref={textareaRef} rows={10} />
<button type="submit">Submit</button>
</form>
);
}

The writetrack/react hook handles client-side concerns automatically:

'use client';
import { useRef } from 'react';
import { useWriteTrack } from 'writetrack/react';
export function ResponseForm() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { isTracking, tracker } = useWriteTrack(textareaRef);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (tracker) {
const data = tracker.getData();
console.log('Session:', data.quality);
}
};
return (
<form onSubmit={handleSubmit}>
<textarea ref={textareaRef} rows={10} />
<button type="submit">Submit</button>
</form>
);
}

For the Pages Router, either use the 'use client' approach above, or use dynamic imports:

import dynamic from 'next/dynamic';
const ResponseForm = dynamic(() => import('../components/ResponseForm'), {
ssr: false,
});
export default function Page() {
return <ResponseForm />;
}

Send captured data to Server Actions:

'use client';
import { useRef } from 'react';
import { useWriteTrack } from 'writetrack/react';
import { submitResponse } from './actions';
export function ResponseForm() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { tracker } = useWriteTrack(textareaRef);
async function handleSubmit(formData: FormData) {
if (tracker) {
formData.set('keystrokeCount', String(tracker.getKeystrokeCount()));
}
await submitResponse(formData);
}
return (
<form action={handleSubmit}>
<textarea ref={textareaRef} name="content" rows={10} />
<button type="submit">Submit</button>
</form>
);
}
actions.ts
'use server';
export async function submitResponse(formData: FormData) {
const content = formData.get('content') as string;
const keystrokeCount = formData.get('keystrokeCount') as string;
// Store or process the submission
await db.responses.create({
content,
keystrokeCount: Number(keystrokeCount),
});
}

WriteTrack resolves its WASM binary with new URL('./writetrack.wasm', import.meta.url). Webpack emits that asset automatically; Turbopack does not treat .wasm as an emitted static asset, so copy the file to public/ and pass wasmUrl. This works under both bundlers, in development and in production, so it is the setup we recommend for every Next.js app:

Terminal window
cp node_modules/writetrack/dist/writetrack.wasm public/writetrack.wasm
const { tracker } = useWriteTrack(textareaRef, {
wasmUrl: '/writetrack.wasm',
});

Add the copy to a predev and prebuild script so it is refreshed after dependency updates.

If getAnalysis() returns null, check the browser console for the failure reason. See Troubleshooting for other bundlers.

Visualization components in the App Router

Section titled “Visualization components in the App Router”

The writetrack/viz web components (scorecard, sparkline, badges) are browser-only, and Server Components reject dynamic(…, { ssr: false }) directly. Add one Client Component boundary and dynamic-import your visuals inside it:

SessionVisualsBoundary.tsx
'use client';
import dynamic from 'next/dynamic';
export const SessionVisuals = dynamic(() => import('./SessionVisuals'), {
ssr: false,
});
SessionVisuals.tsx
'use client';
import { useEffect, useRef } from 'react';
import { WtScorecard } from 'writetrack/viz';
export default function SessionVisuals({ report }: { report: unknown }) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
WtScorecard.register();
(ref.current as HTMLElement & { setData(d: unknown): void })?.setData(
report
);
}, [report]);
return <wt-scorecard ref={ref} />;
}

Server Components can then render <SessionVisuals report={report} /> freely.

Alternatively, send data to API routes:

const handleSubmit = async () => {
if (!tracker) return;
const data = tracker.getData();
await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: textareaRef.current?.value,
typingData: data,
}),
});
};