<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Souhail Makni</title>
    <description>The latest articles on DEV Community by Souhail Makni (@souhailmakni).</description>
    <link>https://dev.to/souhailmakni</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4052090%2Fa0420082-a22c-4c87-b9c7-53a6009c5657.jpg</url>
      <title>DEV Community: Souhail Makni</title>
      <link>https://dev.to/souhailmakni</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/souhailmakni"/>
    <language>en</language>
    <item>
      <title>Google reCAPTCHA v2 and v3 in React, Vue, or Angular, with one prop</title>
      <dc:creator>Souhail Makni</dc:creator>
      <pubDate>Tue, 28 Jul 2026 22:54:39 +0000</pubDate>
      <link>https://dev.to/souhailmakni/google-recaptcha-v2-and-v3-in-react-vue-or-angular-with-one-prop-13d9</link>
      <guid>https://dev.to/souhailmakni/google-recaptcha-v2-and-v3-in-react-vue-or-angular-with-one-prop-13d9</guid>
      <description>&lt;p&gt;Every time I add Google reCAPTCHA to a form I end up rewriting the same glue: load the script exactly once, register and clean up global callbacks, handle the token expiring, and remember that v2 and v3 work nothing alike. So I packaged it into three tiny libraries, one per framework, with the same API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.npmjs.com/package/recaptcha-react" rel="noopener noreferrer"&gt;&lt;code&gt;recaptcha-react&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.npmjs.com/package/recaptcha-vue" rel="noopener noreferrer"&gt;&lt;code&gt;recaptcha-vue&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.npmjs.com/package/recaptcha-angular" rel="noopener noreferrer"&gt;&lt;code&gt;recaptcha-angular&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are zero-dependency, fully typed, tested, and MIT. The part I am happiest with: &lt;strong&gt;you switch between v2 and v3 with a single &lt;code&gt;version&lt;/code&gt; prop.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;recaptcha-react
&lt;span class="c"&gt;# or: npm install recaptcha-vue&lt;/span&gt;
&lt;span class="c"&gt;# or: npm install recaptcha-angular&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The framework itself is a peer dependency, so nothing else is added to your bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why v2 and v3 usually need two different libraries
&lt;/h2&gt;

&lt;p&gt;reCAPTCHA v2 (the "I'm not a robot" checkbox) and reCAPTCHA v3 (invisible, score-based) share a name and almost nothing else:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;v2 checkbox&lt;/th&gt;
&lt;th&gt;v3 score-based&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;a visible widget&lt;/td&gt;
&lt;td&gt;no widget, just a floating badge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token&lt;/td&gt;
&lt;td&gt;arrives when the user clicks&lt;/td&gt;
&lt;td&gt;you call &lt;code&gt;execute(action)&lt;/code&gt; in code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;wait for an event&lt;/td&gt;
&lt;td&gt;get a promise, usually at submit time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most wrappers pick one. These handle both behind one component, and keep the rest of the surface identical: the same &lt;code&gt;verify&lt;/code&gt; callback fires in both cases, so your form logic does not change when you switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  v2: the checkbox
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;React&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ReactRecaptcha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRecaptcha&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;recaptcha-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Form&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isVerified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onVerify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onExpire&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRecaptcha&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReactRecaptcha&lt;/span&gt; &lt;span class="na"&gt;sitekey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"SITE_KEY"&lt;/span&gt; &lt;span class="na"&gt;onVerify&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onVerify&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onExpire&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onExpire&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onError&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isVerified&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Submit&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Vue&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;VueRecaptcha&lt;/span&gt; &lt;span class="na"&gt;sitekey=&lt;/span&gt;&lt;span class="s"&gt;"SITE_KEY"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;verify=&lt;/span&gt;&lt;span class="s"&gt;"onVerify"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;expire=&lt;/span&gt;&lt;span class="s"&gt;"onExpire"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;error=&lt;/span&gt;&lt;span class="s"&gt;"onError"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Angular&lt;/strong&gt; (standalone, works with &lt;code&gt;ngModel&lt;/code&gt; and reactive forms because it implements &lt;code&gt;ControlValueAccessor&lt;/code&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;recaptcha-v2&lt;/span&gt; &lt;span class="na"&gt;sitekey=&lt;/span&gt;&lt;span class="s"&gt;"SITE_KEY"&lt;/span&gt; &lt;span class="na"&gt;(verify)=&lt;/span&gt;&lt;span class="s"&gt;"captcha.onVerify($event)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/recaptcha-v2&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  v3: flip the prop
&lt;/h2&gt;

&lt;p&gt;v3 renders no widget, so instead of waiting for a click you ask for a token when you are about to submit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;captcha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RecaptchaHandle&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;captcha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;g-recaptcha-response&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReactRecaptcha&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;captcha&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;sitekey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"V3_SITE_KEY"&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"v3"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same idea in Vue (&lt;code&gt;await recaptchaRef.value.execute('login')&lt;/code&gt;) and Angular (&lt;code&gt;await this.captcha.execute('login')&lt;/code&gt;). Because &lt;code&gt;execute()&lt;/code&gt; also fires &lt;code&gt;verify&lt;/code&gt;, the &lt;code&gt;useRecaptcha&lt;/code&gt; hook / composable / service you already wired up keeps working.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things that actually bite people, handled
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single script load.&lt;/strong&gt; The Google script is injected once per page even with multiple widgets, each instance uses its own callback names so they never collide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token expiry.&lt;/strong&gt; v2 and v3 tokens are single-use and expire in about 2 minutes. This is the number one production bug with any wrapper: a form that works once in testing, then quietly submits a dead token. The &lt;code&gt;expire&lt;/code&gt; event (v2) and a &lt;code&gt;reset()&lt;/code&gt; you call after every submit keep the state honest. For v3 you just call &lt;code&gt;execute()&lt;/code&gt; again at the next submit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load timeout.&lt;/strong&gt; If the script never loads, you get an &lt;code&gt;error&lt;/code&gt; instead of a form that silently never works.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Do not forget the server
&lt;/h2&gt;

&lt;p&gt;Client state is never proof of anything. Always verify the token on your backend against &lt;code&gt;https://www.google.com/recaptcha/api/siteverify&lt;/code&gt; with your secret key. For v3 the response also includes a &lt;code&gt;score&lt;/code&gt; (0.0 to 1.0) and the &lt;code&gt;action&lt;/code&gt;, so reject low scores and confirm the action matches what you expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;react: &lt;a href="https://www.npmjs.com/package/recaptcha-react" rel="noopener noreferrer"&gt;npm&lt;/a&gt; / &lt;a href="https://github.com/Souhailmakni/recaptcha-react" rel="noopener noreferrer"&gt;github&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;vue: &lt;a href="https://www.npmjs.com/package/recaptcha-vue" rel="noopener noreferrer"&gt;npm&lt;/a&gt; / &lt;a href="https://github.com/Souhailmakni/recaptcha-vue" rel="noopener noreferrer"&gt;github&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;angular: &lt;a href="https://www.npmjs.com/package/recaptcha-angular" rel="noopener noreferrer"&gt;npm&lt;/a&gt; / &lt;a href="https://github.com/Souhailmakni/recaptcha-angular" rel="noopener noreferrer"&gt;github&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback and issues welcome, especially on the API shape and anything I missed for the invisible / compact flows.&lt;/p&gt;

</description>
      <category>react</category>
      <category>vue</category>
      <category>angular</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
