Astro Comment Form: with Turnstile & Prerender
Tags: webdev, astro, javascript
Published: 2023-02-10 10:29:26

---
title: "Astro Comment Form: with Turnstile & Prerender"
published: "true"
description: "Astro Comment Form 🚀 how you can add a comment form to your static Astro app using Hybrid Rendering and a Cloudflare Turnstile Captcha 🤖."
tags: "webdev, astro, javascript"
canonical_url: "https://rodneylab.com/astro-comment-form/"
cover_image: "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0h73qs91n4kv8lwepcnc.png"
---

## 😕 What is Turnstile and what does Prerendering mean?

In this Astro comment form post, we are going to explore the new **Hybrid Rendering** pattern introduced in **Astro 2.0**.  As well as that we integrate Cloudflare&rsquo;s innovative Captcha.  It is like no other Captcha you have seen before!  Prerendering improves the developer experience, making adding a form handler to an otherwise static site a whole lot easier.  **Turnstile** improves user experience and saves visitors having to solve a string of Captcha challenges while still protecting our site from bots.

### Prerendering

Two modern patterns for building web sites are **Server-side rendering (SSR)** and **Static Site Generation (SSG)**.  With SSR each time a visitor loads a web page on their device, the page is generated (from scratch) on a remote server and sent to their device.  With SSG we create all the pages beforehand, and the visitor device instead loads the pre-built page.  SSG offers security and speed benefits.  On the other hand SSR makes it easier to customise the page to each visitor (letting a visitor see posts only from friends in a social sharing app as an example).

Before Astro 2 you had to choose either SSG or SSR for the whole site.  Those days are now gone and now **Astro lets you pick and choose** which pages you want to be SSG and which SSR.  A bonus is that you can keep the whole site SSG (for speed and security benefits) but use the SSR features to add a form handler.  A form handler is just server code to read in form inputs and process them.  Hybrid rendering works by setting the default to SSR.  We must then override that default for each individual page we want to generate statically or **prerender**.

### Turnstile

Captchas are the challenges sites use to check you are not a robot! Using proprietary technology, Cloudflare are able to tell bots apart from human users **without solving complex puzzles** which can present **accessibility issues** for some users and irritate others.

In this post we build up a basic comment form component. You could use this as a starting point for a more sophisticated comment form on a blog site.

## 🧱 What are we Building?

As mentioned above, we will create a comment form component.  This will be fairly basic, as the main objective is to see how to add Turnstile and how Astro hybrid rendering work.  For your own project, you will want to incorporate server side sanitisation and validation as well as, perhaps, some client-side user interface progressive enhancements.

## ⛔️ Astro Comment Form: Turnstile Setup

You will <a aria-label="need a Cloudflare account to use Turnstile: create a Cloudflare account" href="https://dash.cloudflare.com/sign-up">need a Cloudflare account to use Turnstile</a>.  At the time of writing it is in beta and access is free.  Log into the Cloudflare console and select **Turnstile** from the menu on the left.  Next click the **Add site** button.  Turnstile credentials can be limited to work from one or more site domains. We will generate credentials for localhost (as the domain), for testing locally.  When you deploy your site, generate a new set of credentials for your public domain.

![Astro Comment Form: Screen capture Cloud flare console with Turnstile configuration open. The Site name field is set to Localhost and the Domain is localhost.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mfsdv4kfwb68p2b3kg1n.png)

For now, enter `Localhost` in the **Site name** field and `localhost` in the **Domain** field.  I chose the **Non-interactive** option for Widget type, but choose whatever best matches your own needs.


## ⚙️ Getting Started: Astro Comment Form

If you have an existing site you want to try this code on, create a git branch and follow along. If, instead, you are starting from scratch, spin up a new Astro app:

```shell
pnpm create astro astro-prerender-comment-form
cd astro-prerender-comment-form
pnpm astro telemetry disable
pnpm add svelte cloudflare
pnpm add -D wrangler
pnpm dev
```

Because our form handler will run in SSR mode, we need to choose a cloud provider, so Astro can customise the build for it.  We choose Cloudflare here.  We will use Svelte for the form markup, though you could probably get away with just an Astro component if you really wanted to.

I chose the default options in the CLI prompts.  Once the dev server starts, it will let you know the URL it is listening on. By default, this will be `http://localhost:3000`.

If you take a look at `astro.config.mjs` you should see something like this:

```javascript
import { defineConfig } from 'astro/config';

// https://astro.build/config
import svelte from '@astrojs/svelte';

// https://astro.build/config
import cloudflare from '@astrojs/cloudflare';

// https://astro.build/config
export default defineConfig({
	adapter: cloudflare(),
	integrations: [svelte()],
	output: 'server',
});
```
Line `13` is important here; it tells Astro to build the whole site as an SSR app.  We will override this on our pages and in fact only the form handler will be managed by the server. 

## 🤫 Environment Variables

Add your Turnstile credentials to a new `.env` file in the project root directory.  Remember **not to commit** these to your git repo:

```plaintext
PUBLIC_SITE_URL="http://localhost:3000"
#PUBLIC_SITE_URL="http://localhost:8788"
PUBLIC_TURNSTILE_SITE_KEY="0x0123456789ABCDEFGHIJKL"
TURNSTILE_SECRETKEY="0xABCDEFGHIJKLMNOPQRSTUVWXYZ0123456"
```

Update the `PUBLIC_SITE_URL` to wherever your site is running.  You can use the commented out value later when running a Cloudflare preview locally.

Next we write some Astro code 🧑🏽‍🚀.

## 🏡 Astro Home Page

Replace the content in `src/pages/index.astro`:

```astro
---
import CommentForm from '~/components/CommentForm.svelte';
import Layout from '~layouts/Layout.astro';

export const prerender = true;
---

<Layout title="Comment">
	<main>
		<h1>Drop your comment here&hellip;</h1>
		<CommentForm client:load />
	</main>
</Layout>

<style>
	h1 {
		margin-bottom: var(--spacing-6);
		font-size: var(--font-size-3);
	}
</style>
```

Line `5` is where we tell Astro we want to prerender this page, overriding the SSR default set in `astro.config.mjs`.  Astro will generate a static page which can be cached by a global CDN.

I like to use import path aliases (so we have `~/layouts/Layout.astro` instead of `../layouts/Layout.astro` in line `3`).  For this to work update `tsconfig.json` in the project root directory:

```json
{
	"compilerOptions": {
		"baseUrl": ".",
		"paths": {
			"~*": ["src/*"]
		}
	},
	"extends": "astro/tsconfigs/strict"
}
```

You might notice we referenced some CSS custom properties in the Astro file above.  We can define those in a global CSS file and import them to the existing `src/layouts/Layout.astro` file.  That way, the custom properties will be available to us in any pages which use the layout.  Update the layout file:

```astro
---
import '~/styles/fonts.css';
import '~/styles/global.css';

export interface Props {
	title: string;
	description?: string;
}

const { description = '', title } = Astro.props;
---
<!-- TRUNCATED... -->
```

Create a `src/styles/` folder then add a `global.css` file with the following content:

```css
:root {
	--spacing-px-2: 2px;
	--spacing-0: 0;
	--spacing-1: 0.25rem;
	--spacing-2: 0.5rem;
	--spacing-4: 1rem;
	--spacing-6: 1.5rem;
	--spacing-8: 2rem;
	--spacing-12: 3rem;

	--max-width-xs: 20rem;
	--max-width-wrapper: 48rem;

	--font-size-root: 16px;
	--font-size-1: 1rem;
	--font-size-2: 1.25rem;
	--font-size-3: 1.563rem;
	--font-size-4: 1.953rem;
	--font-size-5: 2.441rem;
	--font-size-6: 3.052rem;

	--font-weight-normal: 400;
	--font-weight-bold: 700;

	--colour-dark: hsl(132 100% 1%); /* deep fir */
	--colour-brand: hsl(210 67% 43%); /* denim */
	--colour-theme: hsl(116 46% 50%); /* sugar cane */
	--colour-light: hsl(100 100% 99%); /* sandwisp */
	--colour-alt: hsl(12 85% 60%); /* mandarin pearl */

	--colour-light-alpha-90: hsl(100 100% 99% / 90%);

	--font-family-body: Urbanist;
}

html {
	font-family: var(--font-family-body);
}

body {
	display: grid;
	place-items: center;
	background-color: var(--colour-brand);
	padding: var(--spacing-12);
	color: var(--colour-light);
	accent-color: var(--colour-alt);
	caret-color: var(--colour-brand);
}

main {
	min-height: 100vh;
	width: var(--max-width-xs);
	margin: var(--spacing-4) auto var(--spacing-80);
}

h1 {
	font-size: var(--font-size-6);
	font-weight: var(--font-weight-bold);
}

p {
	font-size: var(--font-size-5);
}

form {
	display: flex;
	flex-direction: column;
	width: var(--max-width-full);
}

.screen-reader-text {
	border: 0;
	clip: rect(1px, 1px, 1px, 1px);
	clip-path: inset(50%);
	height: 1px;
	margin: -1px;
	width: 1px;
	overflow: hidden;
	position: absolute !important;
	word-wrap: normal !important;
}
```

We are still missing the CSS for the fonts. Create `fonts.css` in the same folder:

```css
/* See: https://gwfh.mranftl.com/fonts/urbanist?subsets=latin
 * to customise or download the fonts
 */

/* urbanist-regular - latin */
@font-face {
	font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
	font-family: 'Urbanist';
	font-style: normal;
	font-weight: 400;
	src: url('/fonts/urbanist-v10-latin-regular.woff2') format('woff2'),
		/* Chrome 36+, Opera 23+, Firefox 39+ */ url('/fonts/urbanist-v10-latin-regular.woff')
			format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}

/* urbanist-700 - latin */
@font-face {
	font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
	font-family: 'Urbanist';
	font-style: normal;
	font-weight: 700;
	src: url('/fonts/urbanist-v10-latin-700.woff2') format('woff2'),
		/* Chrome 36+, Opera 23+, Firefox 39+ */ url('/fonts/urbanist-v10-latin-700.woff')
			format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
```

For this to work, we will serve the `.woff` and `.woff2` files from our project.  You can <a href="https://gwfh.mranftl.com/fonts/urbanist?subsets=latin">download them</a> and save the four files (`.woff` and `.woff2` for `400` and `700` weight),  to a new `public/fonts` directory in the project.

We now have the basics out of the way. Hope that wasn&rsquo;t too quick!  Drop a comment below if something needs more explanation.  Next we will add the Svelte form component.  Then, finally we can add the SSR form handler.

## ❤️ Svelte Comment Form

We will add a script tag to the page html `head` section for Turnstile.  Svelte makes it quite easy to do this from within the component.  That helps us encapsulate all the client-site Turnstile code in a single file.  Create `CommentForm.svelte` in the `src/components` directory with the following content:


```svelte
<script lang="ts">
	const siteUrl = import.meta.env.PUBLIC_SITE_URL;
	const turnstileSiteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
</script>

<svelte:head>
	<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</svelte:head>

<form action={`${siteUrl}/api/message`} method="post">
	<label for="name" class="screen-reader-text">Name</label>
	<input id="name" name="name" type="text" required placeholder="Your Name" title="Name" />
	<label for="email" class="screen-reader-text">Email</label>
	<input
		id="email"
		name="email"
		type="text"
		required
		placeholder="river@example.com"
		title="Email"
	/>
	<label for="email" class="screen-reader-text">Comment</label>
	<textarea id="comment" name="comment" required placeholder="Your comment…" rows="5" />
	<button type="submit">Submit</button>
	<div class="cf-turnstile" data-sitekey={turnstileSiteKey} data-size="compact" />
</form>
```

You see the Turnstile code we needed to add is minimal.  We add a script tag in the HTML head section (line `7`). Then, the little widget which shows up below the submit button (line `25`).

We are **using the platform** here; we use built-in JavaScript APIs to submit the form.  You will see the form has `${siteUrl}/api/message` as the action.  That is the route we will set the form handler to listen on.

![Astro Comment Form: Screen capture shows comment form with name, email and comment fields. Below the submit button you can see the Turnstile widget with a tick or check mark and the word success.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/22luy8rfqopirlqeiun8.png)

### Turnstile Verification Process

When a user visits the page, the widget runs in the background and decides if the user is a bot or not (traditionally a user challenge would have been used here).  The script sends data from the client browser to Cloudflare. Cloudflare then responds with a code. We use that code server-side to check if the visitor passed the Captcha (more on that later).  The Turnstile JavaScript code we added will automatically add an extra field with that response code. This is how we get the code from client to server.

### Form Styling

Spruce up the form a touch with some extra styles at the bottom of `src/components/CommentForm.svelte`:

```svelte
<style>
	button {
		all: unset;
		cursor: pointer;
		background-color: var(--colour-light);
		color: var(--colour-brand);
		padding: var(--spacing-2);
		margin-bottom: var(--spacing-6);
		font-size: var(--font-size-3);
		font-weight: var(--font-weight-bold);
		text-align: center;
	}

	button:focus {
		outline: var(--spacing-px-2) solid var(--colour-alt);
	}

	button:focus,
	button:hover {
		background-color: var(--colour-light-alpha-90);
	}

	textarea {
		resize: none;
	}

	button,
	input,
	textarea {
		border-radius: var(--spacing-1);
	}

	input,
	textarea {
		text-indent: var(--spacing-2);
		line-height: 1.75;
		margin-bottom: var(--spacing-4);
		border-radius: var(--spacing-1);
		border-style: none;
		font-size: var(--font-size-2);
	}

	.cf-turnstile {
		margin-left: auto;
	}
</style>
```

## 🥹 Thank you Page

Before we jump to the form handler, let&rsquo;s add a thank you page. We will show this on successful form completion.  Create `src/pages/thanks.astro` with this content:

```svelte
---
import Layout from '~layouts/Layout.astro';

export const prerender = true;
---

<Layout title="Thanks">
	<main>
		<h1>Thanks for submitting your comment!</h1>
		<p>We will be in touch</p>
	</main>
</Layout>

<style>
	body {
		background-color: var(--colour-theme);
		color: var(--colour-dark);
	}
</style>
```

## 🍶 Server-Side Form Handler

We are on the home straight now.  Once we add the form handler, all we will have left to do is test!  In Astro, API routes follow the same file-based routing system as HTML pages.  The main difference is that the file extensions are different.  Create a `src/pages/api` folder and in there, add `message.ts` with the following content:

```typescript
import type { APIRoute } from 'astro';

const siteUrl = import.meta.env.PUBLIC_SITE_URL;
const turnstileSecret = import.meta.env.TURNSTILE_SECRETKEY;

export const post: APIRoute = async function post({ redirect, request }) {
	try {
		const form = await request.formData();
		const name = form.get('name');
		const email = form.get('email');
		const comment = form.get('comment');
		const turnstileResponse = form.get('cf-turnstile-response');

		const ip = request.headers.get('CF-Connecting-IP');

		if (typeof turnstileResponse === 'string') {
			const bodyFormData = new FormData();
			bodyFormData.append('secret', turnstileSecret);
			bodyFormData.append('response', turnstileResponse);
			ip && bodyFormData.append('remoteip', ip);
			const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
				method: 'POST',
				body: bodyFormData,
			});
			const { success } = await response.json();

			console.log({ name, email, comment, success });
		}

		return redirect(`${siteUrl}/thanks`);
	} catch (error: unknown) {
		console.error(`Error in comment form submission: ${error as string}`);
		return redirect(`${siteUrl}/`);
	}
};
```

Again we are using standard JavaScript APIs for form handling.  Remember we said Turnstile adds an extra form field for us?  We pull this in in line `12`.  The final part of the verification process is to send the client response to Cloudflare, along with our secret Turnstile API key.  The server replies with a JSON object including a `success` field.  As you might expect this is `false` when Turnstile assesses the visitor to be a bot and `true` otherwise.

In a real world app, when `success` is `true` we would want to commit the comment data to our database as well as any other processing needed.  We just do a console log here instead.  Also in a production app, we should want to do some sanitisation before inserting the data to the database.  On top we would have some validation so we do not commit junk to the database.  If the comments will be displayed publicly you will also need to filter them checking for inappropriate user-submitted content.

Returning to our basic example, finally we respond with a redirect pushing the visitor browser to our new Thank You page (line `30`).


### 💯 Astro Comment Form: Testing

Try submitting the form from your dev server.  If all goes well, you should see the Thank You page.

![Astro Comment Form: Screen capture shows success page, thanking the visitor for leaving a comment.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f5m4cgiwyxmmyuspj4ph.png)

To build the site locally we need to run in a Cloudflare wrangler environment. Add an extra script to the project `package.json` file to handle this:

```json
{
	"name": "astro-prerender-comment-form",
	"type": "module",
	"version": "0.0.1",
	"private": true,
	"scripts": {
		"dev": "astro dev",
		"start": "astro dev",
		"build": "astro telemetry disable && astro build",
		"preview": "astro preview",
		"preview:cf": "wrangler pages dev ./dist",
	},
	// TRUNCATED...
```

Then run the script from the Terminal:

```shell
pnpm preview:cf
```

This time the site will be available at `http://localhost:8788`.  If this is your first time running wrangler from your machine, follow the instructions in the Terminal to log in.

Remember to update `PUBLIC_SITE_URL` in your `.env` file to match the new URL (otherwise the form will not submit as expected).

That&rsquo;s it! You can now try pushing to Cloudflare Pages.  Create a set of Turnstile credentials for you actual public domain first.

## 🙌🏽 Astro Comment Form: Wrapping Up

In this post, we saw how you can add a server-side form handler to your static Astro site.  In particular we saw:

- how to **add Turnstile Captcha** in Astro,
- how **Astro Hybrid rendering** and **prerendering** work,
- some **points to consider** in a full production comment form.


You can see the <a href="https://github.com/rodneylab/astro/tree/main/demos/astro-prerender-comment-form">full code for this project in the Rodney Lab GitHub repo</a>. I do hope you have found this post useful! I am keen to hear what you are doing with Astro and ideas for future projects.  Also let me know about any possible improvements to the content above.

## 🙏🏽 Astro Comment Form: Feedback

Have you found the post useful? Would you prefer to see posts on another topic instead? Get in touch with ideas for new posts. Also if you like my writing style, get in touch if I can write some posts for your company site on a consultancy basis. Read on to find ways to get in touch, further below. If you want to support posts similar to this one and can spare a few dollars, euros or pounds, please <a aria-label="Support Rodney Lab via Buy me a Coffee" href="https://rodneylab.com/giving/">consider supporting me through Buy me a Coffee</a>.

Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via <a href="https://twitter.com/messages/compose?recipient_id=1323579817258831875">@askRodney</a> on Twitter, <a href="https://toot.community/@rodney">@rodney@toot.community</a> on Mastodon and also the <a aria-label="Contact Rodney Lab via Telegram" href="https://matrix.to/#/%23rodney:matrix.org">#rodney</a> Element Matrix room. Also, see <a aria-label="Get in touch with Rodney Lab" href="https://rodneylab.com/contact/">further ways to get in touch with Rodney Lab</a>. I post regularly on <a aria-label="See posts on Astro" href="https://rodneylab.com/tags/astro/">Astro</a> as well as <a aria-label="See posts on svelte kit" href="https://rodneylab.com/tags/seo/">SEO</a>. Also <a aria-label="Subscribe to the Rodney Lab newsletter" href="https://newsletter.rodneylab.com/issue/latest-issue">subscribe to the newsletter to keep up-to-date</a> with our latest projects.