The Nuxt Content v1 Content.DB database and file size

Background

I've been using NuxtJS successfully for a number of client sites, and this one, for quite a while. One of my clients, MKG Marketing, has produced a lot more content than the others with over 200 podcast episodes most containing a full transcript. It's been going very well with them maintaining all the markdown content in CloudCannon and the site itself being built and published with CloudFlare pages.

MKG, like DamienG.com, is a Nuxt 2 site I haven't upgraded yet - the upgrade Nuxt guide has failed to materialize even though Nuxt 3 has been out for a year and a half so it's probably safe to assume it's not coming. There's some Nuxt Content specific migration guidance available however.

Getting back to the now we started seeing a build error on CloudFlare pages that looks like this:

16:09:05.122    ✘ [ERROR] Error: Pages only supports files up to 25 MiB in size
16:09:05.122    
16:09:05.122      _nuxt/content/db-931b471c.json is 25.1 MiB in size

Why would NuxtJS be generating a file that large? The first thing to do is to dig in and see what's in there...

What is the content.db file?

The content.db is a JSON file created by the @nuxt/content module when building sites in static mode (nuxt generate with target: 'static' in the nuxt.config.js) and placed into the /dist/_nuxt/content folder.

The JSON is minified so if you want to take a peek find a good JSON viewer. I used Firefox which was a bit slow to initially load the file but once it did it was easy to navigate. The contents look like this:

Firefox showing the contents of content.db

This file is actually a serialization of the LokiDB database that Nuxt Content v1 uses for two things:

  1. Client-side querying of content
  2. Serverless full-text search

We can see that in the JSON there a single collection named items which contains all the content under the _data branch for client-side querying and tokenized words for the full-text search under the _fullTextSearch branch further broken down by the search field, e.g. title, description, text etc.

Given that it's quite easy to see how the file could get large with a lot of content. Nuxt v3 doesn't offer serverless full-text search and even offers a way to chunk up the remaining content so this is just a Nuxt v2 and Content v1 problem.

Eliminate the content.db file?

I was advised by the helpful folks at Nuxt that I could just delete the content.db file at the end of the build process if I didn't need full-text-search and my site was truly static.

I tried this quickly by deleting content.db after a yarn generate and then using yarn start and I found some issues so this wasn't going to be as simple for me.

Do not use yarn dev for testing this as there are situation where things work in dev mode but do not work on static serving. You can also search your code base for content access in a mounted hook.

If your site works great then you can add the following to your nuxt.config.js file. If, like me, you need it in some places then skip to the next section about how we can shrink it down.

  hooks: {
    "generate:done": (builder) => {
      const options = builder.nuxt.options
      const hash = options.publicRuntimeConfig.content.dbHash
      const fileName = path.resolve(options.generate.dir, '_nuxt', 'content', `db-${hash}.json`)
      fs.unlinkSync(fileName)
    }
  }

You'll also need to add the necessary imports at the top of the file if they're not already there:

import fs from 'fs'
import path from 'path'

If that works for you great, you're done and off you go! For the rest of us, keep reading...

Shrinking content.db by reducing full-text search data

My first thought was to eliminate the full-text search data. We don't use it and if your site is big enough to hit a 25MB limit it might be time to consider a third-party search service like Algolia which I've used before with success. So let's change that with a tweak to nuxt.config.js:

export default {
  // ...
  content: {
    fullTextSearchFields: () => []
  }

Note: this is a function not an array! If you use an array it will still generate the full-text search data as the values are merged with the default values.

This will prevent the full-text search data from being included cut the content.db significantly. Ours went from just over 25MB to 7MB. Not bad!

Alternatively if you do want a basic search you could just limit this to the fields you want to search on like ['title', 'description'] and still see a significant reduction in size. The text field is the full body of the articles and carries most of the weight.

Shrinking content.db by reducing content

If you still want to reduce the size of that content.db - and mine was still 7MB after removing the full text search - the the next target is the _data branch and the content it holds.

Fixing pages that are loading content client side

In theory NuxtJS shouldn't need this data at build time as it's only used for client-side querying. But when I deleted the content.db it I found some of my components did not load data. They were components using mounted to load data and all but one had both fetch and mounted doing the same thing, e.g.

export default {
  data() {
    return {
      positions: [],
    };
  },

  async fetch() {
    this.positions = await this.$content("about/jobs").sortBy("title").fetch();
  },

  async mounted() {
    this.positions = await this.$content("about/jobs").sortBy("title").fetch();
  },
};

A bell was ringing in my head. When I removed mounted from the component it worked fine in dev mode but then failed to load the data in generate/start mode unless the page was manually refreshed with F5. Client-side page navigation did not load the data.

This is, I believe, a bug in NuxtJS given it works fine in dev mode. I was able to find a workaround when I noticed not ALL of my components had this problem.

The ones that did not have the problem were using a fetchKey function to ensure that their data was page or instance specific. For example the "latest from our blog" section is keyed by category so it shows the correct 3 blog posts for the current category the page is used on. You're not supposed to need a fetchKey if the component uses the same data everywhere but this seems to be broken so let's just give it a constant key.

export default {
  data() {
    return {
      positions: [],
    };
  },

  async fetch() {
    this.positions = await this.$content("about/jobs").sortBy("title").fetch();
  },

  fetchKey: () => "positionsSection",
};

Hey presto! No more loading data client-side when client-side navigation is used!

If your site is now fully working you can go add that generate:done hook to your nuxt.config.js file to purge content.db entirely and you're done!

If however it turns out there are a few places you really do want to use some client-side content access...

The component that does need to load content client-side...

For us it's a component that shows some team members at random. If we use the server-rendered fetch function to select the individuals then it will be random at build-time but then static until the next site build. That's not what we want.

Here's the code we're using to select 4 random team members on the client:

export default {
  props: {
    team: { type: String, required: false },
  },

  data() {
    return {
      people: [{}, {}, {}, {}],
    };
  },

  async mounted() {
    const people = await this.$content("team")
      .sortBy("sequence")
      .sortBy("name")
      .fetch();
    this.people = getRandom(people, 4);
  },
};

function getRandom(arr, n) {
  let result = new Array(n),
    len = arr.length,
    taken = new Array(len);
  while (n--) {
    const x = Math.floor(Math.random() * len);
    result[n] = arr[x in taken ? taken[x] : x];
    taken[x] = --len in taken ? taken[len] : len;
  }
  return result;
}

So because the mounted hook runs client-side it's going to need some content in the _data branch of the content.db file but not all of it.

Nuxt content doesn't include a way to prune just the content we need so we're going to have to do it ourselves but no problem it's only JSON. So adding to our nuxt.config.js:

export default {
  // ...
  hooks: {
    "generate:done": (builder) => {
      const options = builder.nuxt.options
      const hash = options.publicRuntimeConfig.content.dbHash
      const fileName = path.resolve(options.generate.dir, '_nuxt', 'content', `db-${hash}.json`)

      console.log('Pruning content database file:', fileName)
      const data = JSON.parse(fs.readFileSync(fileName, 'utf8'))
      const collection = data._collections[0]
      collection._data = collection._data.filter(item => item.path.startsWith('/team/'))
      fs.writeFileSync(fileName, JSON.stringify(data))
    },
  },

This brings our content.db down to just 53KB!

Technically there's more we could do in there - pruning the body branch of each team profile in our case as we only use the meta - and sorting out the idIndex branch but it doesn't seem to cause any problems here and 53KB is small enough for me.

Conclusion

There we go! That 25MB hard-limit of CloudFlare pages is no longer a problem and you've either eliminated it entirely (no full-text search, no client-side content access) or halved it (no full-text search) or shrunk it to a tiny stub (minimal client-side content access).

Enjoy!

Damien

Email form sender with AWS Lambda, Brevo & reCAPTCHA

Background

In my previous article Email form sender with Nuxt3, Cloudflare, Brevo & reCAPTCHA I showed how to use Nuxt3, Brevo & reCAPTCHA with Cloudflare.

In this article I will show how to use AWS Lambda instead of Cloudflare to send the email.

AWS Lambda configuration

You're going to need an AWS Lambda function so go ahead and create one. I was able to get away with 128MB of memory and a 20 second timeout. You'll also need to configure it as a function URL with the "NONE" auth type to allow anonymous posting to it. Also remember to enable cross-origin resource sharing and set the allowed origin to your website's domain(s) with POST method and you probably want to allow all headers with "*".

This step will also give you the function URL you'll need to copy into your website's form action attribute.

Code

Here's the code to paste into your AWS Lambda function.

/* global fetch */

export const handler = async (event, context) => {
  console.log("Starting contact form function");

  // Validate parameters
  const { firstName, lastName, email, message, token } = JSON.parse(event.body)
    
  if (!firstName || !lastName || !email || !message || !token) {
    return { statusCode: 400, body: JSON.stringify({ statusMessage: "Missing required fields" })}
  }
  
  // Validate captcha
  const verifyResponse = await fetch("https://www.google.com/recaptcha/api/siteverify", {
    method: "POST",
    headers: {
      "content-type": "application/x-www-form-urlencoded",
    },
    body: `secret=${process.env.RECAPTCHA_SECRET}&response=${token}`,
  })
  const verifyResponseBody = await verifyResponse.json()
  if (!verifyResponse.ok) {
    return "Unable to validate captcha at this time."
  }

  if (verifyResponseBody.success !== true) {
    return "Invalid captcha response."
  }
  
  // Send email
  const emailSendResponse = await fetch("https://api.brevo.com/v3/smtp/email", {
    method: "POST",
    headers: {
      accept: "application/json",
      "api-key": process.env.BREVO_KEY,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      to: [{ email: process.env.EMAIL_TO_ADDRESS, name: process.env.EMAIL_TO_NAME }],
      sender: {
        email: email,
        name: `${firstName} ${lastName}`,
      },
      subject: process.env.EMAIL_SUBJECT,
      textContent: message,
    }),
  })

  if (!emailSendResponse.ok) {
    return "Message could not be sent at this time."
  }

  return "Message sent!"
}

Environment Variables

Finally you'll need to configure a few environment variables. These are:

  • RECAPTCHA_SECRET - Your reCAPTCHA secret key
  • BREVO_KEY - Your Brevo API key
  • EMAIL_TO_ADDRESS - The email address you want to send the email to
  • EMAIL_TO_NAME - The name of the person you want to send the email to
  • EMAIL_SUBJECT - The subject of the email

Conclusion

I hope you find this useful. Of course you could always use AWS's own SES service to send the email but there are plenty of examples of how to do that already.

Disclaimer

I am a Brevo partner eligible for commission on sales I make directly with them however I do not receive any compensation for this article or have link-based referrals for commission.

A blog comment receiver for Cloudflare Workers

Background

A number of years back I switched to a static site generator for damieng.com, firstly with Jekyll, and then with Nuxt when I wanted more flexibility. I've been happy with the results and the site is now faster, cheaper, more secure and easier for me to maintain.

The tricky part was allowing user comments without a third-party service like Disqus. I came up with a solution that has comments into markdown files just like the rest of the site content so they can be published as part of the build process.

The accepting new comments side of things was an Azure function that received a comment via a form post and created a PR. If I approve the PR it gets merged and that automatically triggers a site rebuild to bring it in.

Perfect! Or so I thought...

Azure problems

I could go into a lot of detail here but suffice to say that while a million executions per month are free for a function storing it's data in Azure Storage is not and Microsoft will bill you $0.02-$0.03 a month. That's fine until your credit card expires and you discover Azure's billing system is hard-wired to only accept credit cards matching the country you signed up with and you can't change country. (I've moved from the US to the UK since setting up my account there)

So because Microsoft couldn't charge me $0.03 they notified me of intent to shut down my service and the simplest solution was just to rewrite the function to run elsewhere (it was in C#).

Given card processors charge almost 10x this to process the payment Microsoft must lose money by billing people these trivial amounts.

I do not recommend using Azure if you might move country.

Cloudflare Workers to the rescue

So I went ahead and rewrote my comment receiver on my current provider of choice - Cloudflare. I've had success with their Pages product with Nuxt and their free tiers are very generous (100,000 per day for workers).

This time however I thought I'd code it directly for Cloudflare Workers in TypeScript without using Nuxt - just as an experiment more than anything else.

Here's how things went.

Project setup

You start by using the templating tool for Cloudflare by running:

npm create cloudflare@2

Then give your function a name, choose "Hello World" Worker as the template and yes to Typescript.

This will create a new folder with a package.json and tsconfig.json and a src/index.ts file with a simple worker that returns a Hello World response.

Now we'll install one dependency (GitHub's API is trivially easy to use without a library but we're not going near home-rolled YAML encoding) and open up VS Code (or your editor of choice):

cd your-project-name
npm install yaml --save-dev
code .

Okay, time to edit some files!

The code

Replace the contents of src/index.ts with the following which is also available as a Gist:

import { stringify } from "yaml"

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // Make sure this is a POST to /post-comment
    if (request.method !== "POST" || new URL(request.url).pathname !== "/post-comment") {
      return new Response("Not found", { status: 404 })
    }

    // We only accept form-encoded bodies
    if (request.headers.get("content-type") !== "application/x-www-form-urlencoded") {
      return new Response("Bad request", { status: 400 })
    }

    // Get and validate the form
    const form = await request.formData()
    const validationError = validateForm(form)
    if (validationError) {
      return validationError
    }

    // Validate the Turnstile recaptcha if configured to do so
    if (env.TURNSTILE_SECRET_KEY) {
      const passedTurnstile = await isTurnstileValid(form.get("g-recaptcha-response") ?? "")
      if (!passedTurnstile) {
        return new Response("Failed Turnstile validation", { status: 403 })
      }
    }

    // Details required for the branch/filename
    const commentId = crypto.randomUUID()
    const postId = form.get("post_id")?.replace(invalidPathChars, "-")

    // Get the starting point for the github repo
    const repository = await github()
    const defaultBranch = await github(`/branches/${repository.default_branch}`)

    // Create a new branch for the comment
    const newBranchName = `comments-${commentId}`
    await github(`/git/refs`, "POST", {
      ref: `refs/heads/${newBranchName}`,
      sha: defaultBranch.commit.sha,
    })

    // Create a new file for the comment
    const frontmatter = {
      id: commentId,
      date: new Date().toISOString(),
      name: form.get("name") ?? undefined,
      email: form.get("email") ?? undefined,
      avatar: form.get("avatar") ?? undefined,
      url: form.get("url") ?? undefined,
    }

    await github(`/contents/content/comments/${postId}/${commentId}.md`, "PUT", {
      message: `Comment by ${form.get("name")} on ${postId}`,
      content: btoa("---\n" + stringify(frontmatter) + "---\n" + form.get("message")),
      branch: newBranchName,
      author: {
        name: form.get("name"),
        email: form.get("email") ?? env.FALLBACK_EMAIL,
      },
    })

    // Create a pull request for it
    await github(`/pulls`, "POST", {
      title: `Comment by ${form.get("name")} on ${postId}`,
      body: form.get("message"),
      head: newBranchName,
      base: repository.default_branch,
    })

    // Redirect to the thanks page
    return Response.redirect(env.SUCCESS_REDIRECT, 302)

    async function github(path: string = "", method: string = "GET", body: any | undefined = undefined): Promise<any> {
      const request = new Request(`https://api.github.com/repos/${env.GITHUB_REPO}${path}`, {
        method: method,
        headers: {
          Accept: "application/vnd.github+json",
          Authorization: `Bearer ${env.GITHUB_ACCESS_TOKEN}`,
          "User-Agent": "Blog Comments via PR",
          "X-GitHub-Api-Version": "2022-11-28",
        },
        body: body ? JSON.stringify(body) : undefined,
      })

      const response = await fetch(request)
      if (!response.ok) {
        throw new Error(`GitHub API returned ${response.status} ${response.statusText}`)
      }
      return await response.json()
    }

    async function isTurnstileValid(clientTurnstile: string): Promise<boolean> {
      const form = new FormData()
      form.set("secret", env.TURNSTILE_SECRET_KEY)
      form.set("response", clientTurnstile)
      form.set("remoteip", request.headers.get("CF-Connecting-IP") ?? "")
      const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
        body: form,
        method: "POST",
      })

      if (!response.ok) return false
      const json = (await response.json()) as any
      return json.success === true
    }
  },
}

function validateForm(form: FormData): Response | undefined {
  if (form === null) return new Response("Form not decoded", { status: 400 })

  // Validate the form fields
  if (isMissingOrBlank(form.get("post_id"))) return new Response("post_id must not be empty.", { status: 422 })

  if (reservedIds.test(form.get("post_id") ?? ""))
    return new Response("post_id must not use reserved Windows filenames.", {
      status: 422,
    })

  if (isMissingOrBlank(form.get("message"))) return new Response("message must not be empty.", { status: 422 })

  if (isMissingOrBlank(form.get("name"))) return new Response("name must not be empty.", { status: 422 })

  // Validate the email if provided
  if (!isMissingOrBlank(form.get("email"))) {
    if (!validEmail.test(form.get("email") ?? ""))
      return new Response("email must be a valid email address if supplied.", {
        status: 422,
      })
  }

  // Validate the website URL if provided
  if (!isMissingOrBlank(form.get("url"))) {
    try {
      new URL(form.get("url") ?? "")
    } catch {
      return new Response("url must be a valid URL if supplied.", {
        status: 422,
      })
    }
  }
}

function isMissingOrBlank(str: string | null): boolean {
  return str === null || str === undefined || str.trim().length === 0
}

export interface Env {
  FALLBACK_EMAIL: string
  SUCCESS_REDIRECT: string
  GITHUB_REPO: string
  GITHUB_ACCESS_TOKEN: string
  TURNSTILE_SECRET_KEY?: string
}

const invalidPathChars = /[<>:"/\\|?*\x00-\x1F]/g
const validEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const reservedIds = /CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]/i

Configuring API keys

Now you're going to obtain some API keys. You can initially store these in the .dev.vars file using a KEY=value format one per line. It will look a little like:

GITHUB_ACCESS_TOKEN=github_pat_[rest of your personal access token]
TURNSTILE_SECRET_KEY=0x[rest of your Turnstile secret key]

GitHub access token

Now you'll need to generate a GitHub personal access token with:

  • Read access to commit statuses and metadata
  • Read and Write access to code and pull requests

Store the access token GitHub gives you with the KEY GITHUB_ACCESS_TOKEN.

If you want to use the Cloudflare Turnstile service to validate the recaptcha then you'll need to generate a private key and store it in the file with the KEY TURNSTILE_SECRET_KEY. You'll also need to follow the Turnstile client-side render guide on how to integrate the client-side portion and ensure you send the value you get back from the service client side to the server with the name g-recaptcha-response.

Set this in the config file with the key TURNSTILE_SECRET_KEY.

Non-secret configuration

We're almost done now you need to modify the wranger.toml file in your project root with the contents:

name = "create-comment-pr"
main = "src/index.ts"
compatibility_date = "2023-10-10"

[vars]
FALLBACK_EMAIL = "[email protected]"
GITHUB_REPO = "myorg/mysiterepo"
SUCCESS_REDIRECT = "https://mywebsite.com/thanks"

Testing

Now you can start the local test server (which will also handle the Typescript without needing a manual build step) with:

wrangler dev

And test it either by submitting to localhost:8787 or by using a too like Thunder Client in VS Code.

Deploying

Now you're ready to deploy to production!

wrangler secret put GITHUB_ACCESS_TOKEN
[enter your GitHub personal access token]
wrangler secret put TURNSTILE_SECRET_KEY
[enter your Turnstile secret key]
wrangler deploy

Now change your testing tool to published URL and try it out and if that works update your form post to the new location.

Enjoy!

Damien

Email form sender with Nuxt3, Cloudflare, Brevo & reCAPTCHA

I've been using Nuxt quite extensively on the static sites I work on and host and use Cloudflare Pages to host them. It's a great combination of power, flexibility, performance and cost (free).

While one of the sites I manage uses ActiveCampaign successfully for both their newsletters and contact forms, this latest customer just wanted plain emails ideally with no subscription involved.

Thus started my journey to find a simple, free, email service that I could use to send emails from a Nuxt3 site hosted on Cloudflare Pages.

Brevo email sender

Brevo dashboard

What I needed was an email service that I could POST to that would then send the email on to the customers email address with the message from the contact form as well as a reply-to address corresponding to the contact information from the form.

I found Brevo (referral link) which provides a Transactional Email feature that did exactly what I wanted and their free tier has 300 emails/day which is more than enough for their contact form. As a bonus it includes some really great logging and statistics to keep an eye on things and make development and testing a little easier.

The developer docs guide you through using their SDK and while I normally go that route this was such a simple use case that I decided to just use their HTTP API directly and avoid bringing in dependencies especially given this is going to be called from a serverless function (more on that in a moment).

Once you've signed up you need to head over to get a v3 API key which you will need to send in the headers when making the API request.

function sendEmail(fromEmail: string, firstName: string, lastName: string, message: string): string {
  const response = await fetch("https://api.brevo.com/v3/smtp/email", {
    method: "POST",
    headers: {
      accept: "application/json",
      "api-key": "[your brevo api key here]",
      "content-type": "application/json",
    },
    body: JSON.stringify({
      sender: {
        name: `[from name]`,
        email: "[from email address]",
      },
      to: [{ email: "[to name]", name: "[to email address]" }],
      replyTo: {
        email: fromEmail,
        name: `${firstName} ${lastName}`,
      },
      subject: "Web Contact Form",
      textContent: message,
    }),
  })

  if (!response.ok) {
    return "Message could not be sent at this time."
  }
}

Okay, so that part is done but we need to call this from somewhere...

Creating a Nuxt3 server API

I thought about putting a serverless function up on my usual spots - Azure Functions or AWS Lambda - but given the site is already on Cloudflare Pages and they now support Workers it would be nice to keep it together.

Given that the rest of the site is already a static site on Nuxt3 and this infrastructure should include server-side support let's see what we can do.

First off we create a new file called sendContactForm.post.ts and put it into the server/api folder in the Nuxt3 project. This tells Nuxt3 we want a function and that it should be on the server and that it is post only.

A simple API to call our new function would look something like...

export default defineEventHandler(async (event) => {
  // Validate parameters
  const { name, email, message } = await readBody(event)
  if (!name || !email || !message) {
    throw createError({ statusCode: 400, statusMessage: "Missing required fields" })
  }

  return sendEmail(email, name, message)
})

This is a pretty simple function that validates the parameters and then calls our sendEmail function from earlier. To call it from our contact form we'd do something like this:

// Add this to the <script setup lang="ts"> section of your form and wire up fields and button and add client validation.

const email = ref("")
const name = ref("")
const message = ref("")

const result = await fetch("/api/sendContactForm", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    email: email.value,
    name: name.value,
    message: message.value,
  }),
})

const status = await result.text()
//...

You can bind the text boxes using v-model and add some validation to make sure they are filled in correctly before submitting the form.

Now starting local development with yarn dev we can submit the form and see the email come through. (If it doesn't then check the console log for errors or Brevo's great logs page to see what happened).

reCAPTCHA to prevent spam

Now that we have a working contact form we need to prevent spam. I've used reCAPTCHA before and it works great. There are two basic parts - the page executes some JS from Google that generates a token that is tied to what they think about the users behavior and likely spammyness.

We then take that token and sent it to our API which calls Google with the token and our secret key and Google tells us the score. Literally with v3 we get a score between 0 and 1. If it's less than 0.5 then it's probably spam and we can reject it.

To get going you first sign up with Google reCAPTCHA and create a new "site". You'll need the site key and secret key for the next steps. You can add "localhost" to the domains for local testing.

Client-side detection

Now we need to add the reCAPTCHA script to our site. The easiest way is to add the vue-recaptcha-v3 package to our project.

yarn add vue-recaptcha-v3

Then we need create a new plugin in plugins/google-recaptcha.ts:

import { VueReCaptcha } from "vue-recaptcha-v3"

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(VueReCaptcha, {
    siteKey: "[your site key here]",
    loaderOptions: {
      autoHideBadge: true,
      explicitRenderParameters: {
        badge: "bottomright",
      },
    },
  })
})

Next we'll need to call it from our contact form to get a token we can send to the server when the form is submitted.

<script setup lang="ts">
import { useReCaptcha } from "vue-recaptcha-v3"

const recaptchaInstance = useReCaptcha()
const recaptcha = async () => {
  await recaptchaInstance?.recaptchaLoaded()
  return await recaptchaInstance?.executeRecaptcha("emailSender")
}

const sendMessage = async (e: Event) => {
    const token = await recaptcha()
    const result = await fetch("/api/sendContactForm", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        token: token,
        email: email.value,
        firstName: firstName.value,
        lastName: lastName.value,
        message: message.value,
      }),
    })
})
//...
</script>

Server-side validation

Now that we have the client-side taken care of we turn to the server-side which will send the token to Google and get the score. Here's a little function to do just that.

function validateCaptcha(token: string): string {
  const response = await fetch("https://www.google.com/recaptcha/api/siteverify", {
    method: "POST",
    headers: {
      "content-type": "application/x-www-form-urlencoded",
    },
    body: `secret=${process.env.RECAPTCHA_SITE_KEY}&response=${token}`,
  })

  const verifyBody = await response.json()
  if (!verifyBody.ok)
    return "Unable to validate captcha at this time."
  if (verifyBody.success !== true || verifyBody.score < 0.5)
    return "Invalid captcha response."

  return "ok"
}

And now we can revise our handler to call our new validation function and decide whether to send the email or return an error.

export default defineEventHandler(async (event) => {
  // Validate parameters
  const { firstName, lastName, email, message, token } = await readBody(event)
  if (!firstName || !lastName || !email || !message) {
    throw createError({ statusCode: 400, statusMessage: "Missing required fields" })
  }

  const captchaResult = await validateCaptcha(token)
  if (captchaResult !== "ok")
    return captchaResult

  return sendEmail(email, firstName, lastName, message)
})

Deploying to Cloudflare Pages

Now I knew that Cloudflare Pages supported serverless functions and that Nuxt3 supported static site generation and serverless functions but it had its own syntax and I wasn't sure how it held together. The good news is it does, specifically:

  1. You have to use build and not generate or Nuxt will skip building the functions
  2. Nuxt can convert the server/api folder into serverless functions
  3. It uses a server called Nitro to do this
  4. Nitro supports multiple modes including three for Cloudflare alone
  • cloudflare which uses the service-worker "Cloudflare Worker" syntax
  • cloudflare-pages (combining everything into a _worker.js file)
  • cloudflare-module

The docs say it should detect the environment and do the right thing but you can always override it in your nuxt.config.ts file (you'll also need to do this if you're wanting to check what it generates locally):

// https://nuxt.com/docs/api/configuration/nuxt-config
import { defineNuxtConfig } from "nuxt/config"

export default defineNuxtConfig({
  // ...
  nitro: {
    preset: "cloudflare-pages",
  },
  // ...
})

If you are using the Cloudflare Wrangler tool or GitHub actions to deploy then you'll probably need to search around the web for more information. If you're using the Cloudflare Pages UI then just make sure the tech stack is set for Nuxt and that it's using yarn build (you'll also need to set your environment variable for RECAPTCHA_SITE_KEY).

Final testing

Once that's all in place you should be able to go into the Cloudflare Pages UI and see that it now has functions detected. Head over to your contact form, fill in the details and hit send!

If all goes well you should see the email come through and the result of the form submission should be "ok". If you get an error then check the console log for errors or the Brevo logs to see what happened.

Conclusion

This was a fun little project to put together and I'm really happy with the result. I'm sure there are other ways to do this but this is a nice combination of free services that work well together and are easy to set up and maintain.

I hope you found this useful!

Damien

Disclaimer

I am a Brevo partner eligible for commission on sales I make directly with them however I do not receive any compensation for this article or have link-based referrals for commission.