Server actions made signup forms in the App Router pleasantly short: a form, a function marked 'use server', a database write. They also made it easy to forget that the function is a public POST endpoint. Anyone can call it as often as they like, with a fresh email each time, and every call hands out another free trial.
This guide adds a device-level limit to that action without tying you to an auth provider. The browser identifies the device, the server action verifies the identification with a secret key, counts how many accounts already live on that device, and links the new user afterwards. It is the same three-step recipe behind every Prynt signup integration, translated to App Router idioms.
The shape of the flow
- Client: on submit, call
getData()fromuseVisitorData()and add therequestIdto the form data. - Server action:
GET /v1/events/{requestId}with your secret key. Refuse whendecisionisblockoraccountsOnDevice.countreaches your limit. - Server action, after the user exists:
PUT /v1/events/{requestId}with{ linkedId: user.id }, so the next signup from that device sees this account.
The browser never decides anything. It only carries an opaque requestId to your server, which is why a tampered client cannot talk its way into a second trial.
Provider in the root layout
@prynt/react exposes a provider and a hook. The provider is a client component, so wrap it in a small file and use it from your root layout.
// app/providers.tsx
'use client';
import { PryntProvider } from '@prynt/react';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<PryntProvider apiKey={process.env.NEXT_PUBLIC_PRYNT_KEY!}>
{children}
</PryntProvider>
);
}
The pk_live_… key is public by design and safe to expose through NEXT_PUBLIC_. The sk_… secret key is not, and it never appears in a file with 'use client'.
Identify at submit time
You could identify on mount and drop the requestId into a hidden input, but identifying at submit keeps the event fresh and tags it with the action. Turn off the automatic call with immediate: false and run getData() when the user clicks the button.
// app/signup/signup-form.tsx
'use client';
import { useState } from 'react';
import { useVisitorData } from '@prynt/react';
import { signup } from './actions';
export function SignupForm() {
const { getData } = useVisitorData({ immediate: false });
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
const form = new FormData(e.currentTarget);
const visitor = await getData({ tag: { action: 'signup' } }); // null on error
form.set('requestId', visitor?.requestId ?? '');
const res = await signup(form);
setPending(false);
if (!res.ok) setError(res.error);
else window.location.assign('/onboarding');
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button disabled={pending}>Start free trial</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
Calling the server action directly from the handler, rather than through useActionState, avoids dispatching an action after an await outside a transition. It is plain async code, which also makes the order of operations obvious: identify, then submit.
Note that getData() resolves null when identification fails, for example when a script blocker strips the agent. The form still submits with an empty requestId, and the server decides what an unidentified signup means.
The server action
The Node SDK wraps both API calls. Keep one client instance at module scope.
// app/signup/actions.ts
'use server';
import { PryntServer, type PryntEvent } from '@prynt/node';
import { createUser } from '@/lib/users'; // your auth layer
const prynt = new PryntServer({ secretKey: process.env.PRYNT_SECRET_KEY! });
const MAX_ACCOUNTS_PER_DEVICE = 1; // one trial per device
const MAX_EVENT_AGE_MS = 15 * 60 * 1000;
type Result = { ok: true } | { ok: false; error: string };
export async function signup(form: FormData): Promise<Result> {
const email = String(form.get('email') ?? '');
const password = String(form.get('password') ?? '');
const requestId = String(form.get('requestId') ?? '');
let event: PryntEvent | null = null;
if (requestId) {
try {
event = await prynt.getEvent(requestId);
} catch (err) {
console.warn('prynt lookup failed, failing open', err); // outage: don't block real users
}
}
// Stale identification: treat it as missing (flag, don't trust).
const createdAt = event?.createdAt ? Date.parse(event.createdAt) : 0;
if (event && Date.now() - createdAt > MAX_EVENT_AGE_MS) event = null;
if (event) {
const reused = Boolean(event.linkedId); // already attached to some account
if (event.decision === 'block' || reused) {
return { ok: false, error: "We couldn't create an account from this device." };
}
if (event.accountsOnDevice.count >= MAX_ACCOUNTS_PER_DEVICE) {
return { ok: false, error: 'This device already has a trial. Sign in or pick a plan.' };
}
}
const user = await createUser({ email, password, flagged: !event });
if (event) {
await prynt.updateEvent(requestId, { linkedId: String(user.id) }).catch(() => {});
}
return { ok: true };
}
A few choices in there deserve a sentence each.
Missing identification is flagged, not blocked. Prynt honors Global Privacy Control by default, and some visitors run blockers. Turning “no JavaScript signal” into “no account” punishes privacy-conscious buyers. Creating the account with a review flag, or withholding trial credits until email verification, is the softer default the official recipes use.
Replay protection. A requestId is not single-use and does not expire, so an attacker could capture one clean identification and reuse it. Two cheap guards close that: reject an event that already carries a linkedId, and treat events older than a few minutes as missing, which means the signup goes through the same flagged path as an unidentified one.
The limit counts other accounts. accountsOnDevice lists every linkedId your environment has attached to that device, with firstSeenAt and lastSeenAt. With a limit of one, the first signup sees zero and passes; the second sees one and stops. If your product legitimately expects shared machines, such as a family laptop or a coworking desk, raise the limit to two and log anything above it.
Fail open on errors. A timeout or network error from the lookup should not take your signup down. The SDK times out after five seconds by default and throws a typed PryntError; catch it, log it, and continue. See server-side verification in Node for the full error model.
Edge vs Node runtime
Server actions run on the Node.js runtime unless the route segment sets export const runtime = 'edge'. @prynt/node imports node:crypto for webhook verification and unsealing, so keep it on Node. If your signup route must run on the Edge, skip the SDK and call the REST API with fetch:
const res = await fetch(`https://api.pryntid.com/v1/events/${encodeURIComponent(requestId)}`, {
headers: { Authorization: `Bearer ${process.env.PRYNT_SECRET_KEY}` },
});
const event = res.ok ? await res.json() : null;
// later, after creating the user
await fetch(`https://api.pryntid.com/v1/events/${encodeURIComponent(requestId)}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.PRYNT_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ linkedId: String(user.id) }),
});
Add your own AbortController timeout there, because a bare fetch will wait as long as the platform allows.
Where the linkedId update belongs
Link only after the user row exists and you have its permanent id. If createUser throws, there is nothing to link, and a failed signup should not count against the device. If you create users through a provider webhook instead (Clerk’s user.created, Supabase’s auth hooks), pass the requestId through user metadata and link from the webhook. The Clerk recipe in the repo’s integrations/ folder does exactly that (walked through in Clerk free-trial abuse in Next.js) and adds a ban for anything that slipped past the pre-check.
Tuning before you enforce
Start with MAX_ACCOUNTS_PER_DEVICE set high and log every would-be refusal. A week of logs tells you how often real users share devices and how concentrated the repeat signups are. The free-trial farming guide covers the patterns to look for, and the React integration post covers loading states and errors if your form lives deeper in a client tree.
You can try identifications and inspect accountsOnDevice on the playground, and the docs list every field on the event. Once the logs look right, drop the limit to one and ship.
Try it free
Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.