Custom forms
Solve a captcha challenge in a form you built yourself - the useCaptcha hook's isReady, getToken and onReset, and where to mount the widget.
AutoForm handles captcha for you, and most of the time that is the end of it.
When you are rendering the form yourself - a hand-built <form>, a wizard, a
component that is not an AutoForm at all - useCaptcha is the same three
values AutoForm uses internally.
There is no captcha adapter to write: the two providers live in core, and
type is a closed union. What this page is about is the client half.
Quick start
Three values, and a <div> for the widget to land in.
import type React from 'react'
import { useCaptcha } from '@vitnode/core/hooks/use-captcha'
import { type MiddlewareConfig } from '@vitnode/core/tanstack/auth'
import { sendMessage } from './send-message'
export const ContactForm = ({
captcha,
}: {
captcha: MiddlewareConfig['captcha']
}) => {
const { isReady, getToken, onReset } = useCaptcha(captcha)
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
await sendMessage({
message: new FormData(event.currentTarget).get('message') as string,
captchaToken: await getToken(),
})
onReset()
}
return (
<form onSubmit={onSubmit}>
<textarea name="message" />
<div id="vitnode_captcha" />
<button disabled={!isReady} type="submit">
Send
</button>
</form>
)
}On an installation with no captcha configured, captcha is undefined,
getToken() resolves to '' and isReady flips to true as soon as the
hook's effect runs - so this is one code path, not two. isReady starts false
either way, which means a server-rendered submit button is disabled in the
initial HTML and unlocks on hydration.
The hook
const { isReady, getToken, onReset } = useCaptcha(captcha)It takes exactly what the API publishes on
GET /api/@vitnode/core/middleware, so you pass the value through rather than
building it.
Prop
Type
Wire it up
Gate the route
Nothing on the client makes a route require a token - withCaptcha: true on the
route does, in a plugin exactly as in core.
export const createContactRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
route: {
method: 'post',
path: '/',
description: 'Send a message to the site owner',
withCaptcha: true,
// ...
},
handler: async (c) => c.text('Message sent', 201),
})Read the deployment's config in the plugin screen
The site key is public. A plugin page can read it with the shared TanStack Query definition before rendering its form:
import { useSuspenseQuery } from '@tanstack/react-query'
import { middlewareConfigQueryOptions } from '@vitnode/core/tanstack/auth'
import { ContactForm } from '../views/contact-form'
export const ContactScreen = () => {
const { data: config } = useSuspenseQuery(middlewareConfigQueryOptions())
return <ContactForm captcha={config.captcha} />
}Mount the widget
useCaptcha finds its container by id, so the element has to exist in the DOM
before the provider script finishes loading. Render it unconditionally inside
the form:
<div id="vitnode_captcha" />Turnstile renders into it. reCAPTCHA v3 has nothing to draw, so the <div> is
harmless there and keeping it means the two providers swap without a code
change.
Send the token
captchaToken is a first-class fetcher option, on both the browser fetcher and
the server one, and it spells the x-vitnode-captcha-token header for you. An
empty string sends no header at all.
import type { contactModule } from '@vitnode/my-plugin/api/modules/contact/contact.module'
import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client'
const contact = clientModule<typeof contactModule>('@vitnode/my-plugin')
export const sendMessage = async ({
captchaToken,
...body
}: {
captchaToken: string
message: string
}) =>
await fetcherClient(contact, {
method: 'post',
module: 'contact',
path: '/',
captchaToken,
args: { body },
})Verify it is really gated
Call the route with no token. It answers 400 before your handler runs:
curl -i -X POST 'http://localhost:3000/api/@vitnode/my-plugin/contact' \
-H 'Content-Type: application/json' \
-d '{"message":"Hello from curl"}'HTTP/1.1 400 Bad Request
Captcha token is requiredThen open the form. With Turnstile the widget appears where you put the <div>
and the submit button unlocks on success; with reCAPTCHA v3 the button is
enabled from the start and the token is minted on submit.
Where the pieces live
A minimal captcha-gated feature is four files, and only one of them knows the hook exists.
Gotchas
One #vitnode_captcha per page
The container is found by id, so two captcha forms on one page fight over it: the challenge lands in whichever rendered first and the other's submit button never unlocks. Put the second form behind a dialog, a tab or its own route.
The hook needs VitNode's providers above it
useCaptcha reads the visitor's locale and its error copy through use-intl,
and the resolved theme through VitNode's ThemeProvider - the widget is
rendered in both. use-intl throws without a provider, so the hook has to be
mounted under VitNodeRootProviders, which every VitNode TanStack Start app
already has above its route tree. Mount it outside and you get an intl context
error, not a captcha.
Call onReset(), but know what it costs on v3
Without it a visitor can submit the same token twice. With it, isReady goes
back to false - and on reCAPTCHA v3 nothing sets it back to true, because
there is no widget callback. A form that stays on screen after a failed
submission ends up with a dead button until it remounts. Turnstile re-arms
itself.
A missing script is not an error you can catch
If the provider's script is blocked, getToken() resolves to '' rather than
rejecting. Treat the API's 400 as the real signal - it is the only place
that knows whether the token was any good.