Skip to content

Instantly share code, notes, and snippets.

@suhaotian
Last active December 31, 2024 02:26
Show Gist options
  • Save suhaotian/c2851d1938da31d349e8cfe65c97c47e to your computer and use it in GitHub Desktop.
Save suhaotian/c2851d1938da31d349e8cfe65c97c47e to your computer and use it in GitHub Desktop.
Cloudflare turnstile for next.js
'use client';
import Script from 'next/script';
import { useEffect, useRef, useState } from 'react';
const scriptLink = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
type TurnstileRenderParameters = Turnstile.RenderParameters;
export default function Captcha(
props: Pick<
TurnstileRenderParameters,
'action' | 'cData' | 'callback' | 'tabindex' | 'theme' | 'language'
> & {
sitekey?: TurnstileRenderParameters['sitekey'];
errorCallback?: TurnstileRenderParameters['error-callback'];
expiredCallback?: TurnstileRenderParameters['expired-callback'];
}
) {
const { sitekey, errorCallback, expiredCallback, ...rest } = props;
const widgetID = useRef<string>();
const [isError, setIsError] = useState(false);
function retry() {
setIsError(false);
}
function onError(e?: string | Error) {
console.log(`Captcha error`, e);
setIsError(true);
if (errorCallback) {
errorCallback();
}
}
function renderWidget() {
try {
widgetID.current = turnstile.render('#captcha-container', {
...rest,
// Refer: https://developers.cloudflare.com/turnstile/reference/testing/
sitekey: sitekey || process.env.NEXT_PUBLIC_TURNSLITE_SITE_KEY || '',
'error-callback': onError,
'expired-callback': expiredCallback,
});
if (!widgetID.current) {
throw new Error(`turnstile.render return widgetID=${widgetID.current}`);
}
} catch (e: unknown) {
onError(e as Error);
}
}
function onLoad() {
renderWidget();
}
useEffect(() => {
if (!widgetID.current && (window as any).turnstile) {
renderWidget();
}
return () => {
(window as any).turnstile?.remove(widgetID.current || '');
widgetID.current = undefined;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (isError) {
return (
<div className="text-red-500 bg-white shadow-lg" onClick={retry}>
Load captcha error
<span className="text-blue-500 cursor-pointer inline-block text-sm font-semibold ml-2">
Retry
</span>
</div>
);
}
return (
<>
<div id="captcha-container"></div>
<Script
src={scriptLink}
onLoad={onLoad}
onError={(e) => onError('load error: ' + e.message)}
/>
</>
);
}
/**
*
* @category captcha
*/
type CaptchaTurnstileVerifyRes = {
success: boolean;
'error-codes': string[];
challenge_ts: string;
hostname: string;
};
function captchaTurnstileVerify({ validateToken, ip }: { validateToken: string; ip: string }) {
const data = {
// Refer: https://developers.cloudflare.com/turnstile/reference/testing/
secret: config.TURNSLITE_SECRET_KEY,
response: validateToken,
remoteip: ip,
};
return axios
.post<CaptchaTurnstileVerifyRes>(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
data,
{
headers: { 'Content-Type': 'application/json' },
}
)
.then((res) => res.data);
}
}
// https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/
declare const turnstile: Turnstile.Turnstile;
declare namespace Turnstile {
interface Turnstile {
/**
* Invokes a Turnstile widget and returns the ID of the newly created widget.
* @param container The HTML element to render the Turnstile widget into. Specify either the ID of HTML element (string), or the DOM element itself.
* @param params An object containing render parameters as key=value pairs, for example, {"sitekey": "your_site_key", "theme": "auto"}.
* @return the ID of the newly created widget, or undefined if invocation is unsuccessful.
*/
render(container: string | HTMLElement, params: RenderParameters): string | undefined;
/**
* Resets a Turnstile widget.
* @param widgetId The ID of the Turnstile widget to be reset.
*/
reset(widgetId: string): void;
/**
* remove a Turnstile widget.
* @param widgetId The ID of the Turnstile widget to be removed.
*/
remove(widgetId: string): void;
/**
* Gets the response of a Turnstile widget.
* @param widgetId The ID of the Turnstile widget to get the response for.
* @return the response of the Turnstile widget.
*/
getResponse(widgetId: string): string;
}
/**
* The theme of the Turnstile widget.
* The default is "auto", which respects the user preference. This can be forced to "light" or "dark" by setting the theme accordingly.
*/
type Theme = 'auto' | 'light' | 'dark';
/**
* Parameters for the turnstile.render() method.
*/
interface RenderParameters {
/**
* Your Cloudflare Turnstile sitekey. This sitekey is associated with the corresponding widget configuration and is created upon the widget creation.
*/
sitekey: string;
/**
* Optional. A customer value that can be used to differentiate widgets under the same sitekey in analytics and which is returned upon validation.
*/
action?: string | undefined;
/**
* Optional. A customer payload that can be used to attach customer data to the challenge throughout its issuance and which is returned upon validation.
*/
cData?: string | undefined;
/**
* Optional. A JavaScript callback that is invoked upon success of the challenge.
* The callback is passed a token that can be validated.
*/
callback?: (token: string) => void;
/**
* Optional. A JavaScript callback that is invoked when a challenge expires.
*/
'expired-callback'?: VoidFunction;
/**
* Optional. A JavaScript callback that is invoked when there is a network error.
*/
'error-callback'?: (error?: string | Error) => void;
/**
* Optional. The widget theme.
* Accepted values: "auto", "light", "dark"
* @default "auto"
*/
theme?: Theme | undefined;
/**
* Optional. The tabindex of Turnstile’s iframe for accessibility purposes.
* @default 0
*/
tabindex?: number | undefined;
/**
* Language to display, must be either: auto (default) to use the language that the visitor has chosen, or an ISO 639-1 two-letter language code (e.g. en) or language and country code (e.g. en-US).
* refer: https://developers.cloudflare.com/turnstile/reference/supported-languages/
*/
language?: string;
}
}
'use client';
import Link from 'next/link';
import { useForm, SubmitHandler } from 'react-hook-form';
import { ZodError } from 'zod';
import CaptchaWidget from '@/components/CaptchaWidget';
import { CaptchaTurnstileVerifyReq, CaptchaTurnstileVerify } from '@/lib/admin-api';
export default function CaptchaDemo() {
const {
register,
handleSubmit,
watch,
formState: { errors },
getValues,
setValue,
setError,
} = useForm<CaptchaTurnstileVerifyReq>();
const onSubmit: SubmitHandler<CaptchaTurnstileVerifyReq> = (data) => verify();
async function verify() {
try {
const res = await CaptchaTurnstileVerify(getValues());
if (res.success) {
alert('Success');
} else {
alert(res['error-codes'].join(', '));
}
} catch (e) {
if (Array.isArray(e)) {
(e as ZodError['errors']).forEach((item) => {
setError(item.path[0] as any, { message: item.message });
});
}
}
}
return (
<div>
<h3>
Captcha Demo
<Link className="text-blue-500 ml-4 cursor-pointer" href="/demoss">
return
</Link>
</h3>
<br />
<input {...register('email')} className="border" />
{errors.email ? <div>{errors.email.message}</div> : null}
<br />
<input {...register('firstName')} className="border" />
{errors.firstName ? <div>{errors.firstName.message}</div> : null}
<br />
<div>
<CaptchaWidget
callback={(validateToken) => {
setValue('validateToken', validateToken);
}}
/>
</div>
{errors.validateToken ? <div>{errors.validateToken.message}</div> : null}
<button type="submit" onClick={verify} className="bg-blue-500 p-4 rounded-md text-white">
Verify
</button>
</div>
);
}
@subvertallchris
Copy link

Thank you for this! It works great.

You might consider adding turnstile to the Window object instead of using any:

declare global {
  interface Window {
    turnstile?: {
      render: (container: string, options: TurnstileRenderParameters) => string;
      remove: (widgetId: string) => void;
    };
  }
}

@suhaotian
Copy link
Author

@subvertallchris Cool! Thanks!

@vnxcius
Copy link

vnxcius commented Aug 20, 2024

You just saved me a LOT of time, thanks dude. Wish you the best

@ivan-khuda
Copy link

Thank you!

@sajanv88
Copy link

sajanv88 commented Oct 5, 2024

How can i use this along with react-hook-form and zod?

@suhaotian
Copy link
Author

How can i use this along with react-hook-form and zod?

You can add a onChange props to the component, and change it to callback in turnstile.render method

@sajanv88
Copy link

sajanv88 commented Oct 5, 2024

Indeed. I ended up something like this for now.

'use client';
import { createContext, ReactNode, useContext, useState } from 'react';
import TurnstileCaptcha from '@/app/shared/turnstile-captcha';

const TurnstileContext = createContext<Partial<{ token: string }>>({});

interface Props {
  children: ReactNode;
  className?: string;
}

export function useTurnstile() {
  const ctx = useContext(TurnstileContext);
  if (!ctx) {
    throw new Error('useTurnstile must be used within a TurnstileProvider');
  }
  return ctx;
}

export default function TurnstileProvider({ children }: Props) {
  const [token, setToken] = useState<string | undefined>(undefined);

  return (
    <TurnstileContext.Provider value={{ token }}>
      <>
        {children}
        <TurnstileCaptcha
          onValidationToken={(token) => {
            setToken(token);
          }}
          sitekey="sitekey" />
      </>
    </TurnstileContext.Provider>
  );
}

interface Props {
  onValidationToken: (token: string) => void;
  sitekey: string;
}
function TurnstileCaptcha({ onValidationToken, sitekey }: Props) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (!ref.current) return;
    const isTurnstileExisit = ref.current.querySelector('input[name="cf-turnstile-response"]');
    if (isTurnstileExisit) return;
    if (window.turnstile) {
      window.turnstile.render(ref.current, {
        sitekey,
        callback: (token: string) => {
          onValidationToken(token);
        },
      });
    }
  }, [ref.current]);
  return (
    <div ref={ref}  />
  );
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment