Fri Feb 10 2023

Blog comments using Astro and Supabase

At first I really wasn’t sure if I wanted to implement a comments system from scratch. There are lots of good options available out of the box that support comments like welcomments and jamcomments . Ultimately, however, it came down to me being stingy bastard and preferring a bit of a struggle handcoding it than paying 10 buckeroos a month.

A disclaimer

It’s worth pointing out nice an early that I haven’t done this the static site way. The typical static site way would be to have the comments statically defined in the HTML and only updated when the whole site is updated via git. Or perhaps some combination of static comments and hydration on the client. I avoided all this for a simple client-side only approach where the comments and fetched from the database when the page loads. This is slightly less performant that the aforementioned alternative, but it’s simpler to code, and makes it easier to provide a nice UX.

Adding a comment to the database

Scroll down and take a look at the comments section. This is what we are talking about. What happens when you add a comment?

The HTML for the form looks like this:

<!-- Comments.astro -->

<form id="add-comment">
	<!-- Unique to each blog post -->
	<input type="hidden" aria-hidden="true" name="id" value="{id}" />
	<label for="name-field-comment">Name</label>
	<input type="text" name="name" id="name-field-comment" required />

	<!-- Honeypot -->
	<fieldset class="visually-hidden" aria-hidden="true">
		<label for="last-name-field">Last Name:</label>
		<input
			type="text"
			id="last-name-field"
			name="last_name"
			autocomplete="off"
			tabindex="-1"
		/>
	</fieldset>
	<label for="comment-field">Comment</label>
	<textarea rows="6" name="comment" id="comment-field" required></textarea>
	<div class="text-right">
		<input type="submit" id="comment-submit-btn" value="Add Comment" />
	</div>
</form>

Pretty standard HTML form right? But what about this bit?

<input type="hidden" aria-hidden="true" name="id" value="{id}" />

This will make sense later but essentially it’s a unique identifier for each blog post. This makes it easy to determine which comments belong to which blog post. The id begins it’s life in the blog posts markdown file in the frontmatter id: "my-id", is then passed to the Comments component as a prop, and finally is added to the form as a hidden input.

// In the blog post (blog-post.mdx)
---
layout: ../../layouts/BlogLayout.astro
id: "build-blog-pt-4"
---

// In the Blog Layout component (BlogLayout.astro)
<Comments id="{frontmatter.id}" />

// In the Comments component (Comments.astro)
---
const { id } = Astro.props;
---

<input type="hidden" aria-hidden="true" name="id" value="{id}" />

The string build-blog-pt-4 is passed through a few components for use in our comment form.

Now let’s take a look at the JavaScript that handles the form submission.

const commentForm = document.getElementById("add-comment");
commentForm.addEventListener("submit", async event => {
	event.preventDefault();
	const formData = Object.fromEntries(new FormData(commentForm));
	const response = await fetch("/.netlify/functions/add-comment", {
		method: "POST",
		body: JSON.stringify({
			name: formData.name,
			comment: formData.comment,
			last_name: formData.last_name,
			blog_id: formData.id,
			blog_url: window.location.href
		})
	}).then(result => result.json());
	commentForm.reset();
	if (response.status === 429) {
		alert(
			"Whoa there, slow down. Maximum of 2 comments every minute please! 🙏"
		);
	}
	if (response.message !== "Honeypot triggered") {
		getComments();
	}
});

There are a few things happening here:

  1. We get the form element and add an event listener for the submit event.
  2. We prevent the default form submission behaviour.
  3. We get the form data and convert it to a JavaScript object.
  4. We send the form data to our serverless function.
  5. We reset the form.
  6. We check the response from the serverless function.
  7. If the response is a 429 (too many requests) we alert the user.
  8. If the response is not a honeypot trigger we fetch the comments again.

Honeypots

I go into detail about how honeypots work in a previous blog post so I won’t cover it in detail. The short version is that we add a hidden field to the form that is hidden from the user. If the user fills out the field we know they are a bot and we can ignore the form submission.

Rate Limiting

You can see that it’s possible for the user to get a 429 - Too many requests response. But how did I do this?

Let’s take a look at the serverless function that we post the data too.

import * as dotenv from "dotenv";
import { createClient } from "@supabase/supabase-js";
const rateLimit = require("lambda-rate-limiter")({
	interval: 60 * 1000 // Our rate-limit interval, one minute
}).check;

dotenv.config();

// Create a single supabase client for interacting with your database
const supabase = createClient(process.env.DATABASE, process.env.DATABASE_KEY);

export const handler = async (event, context) => {
	const { name, comment, blog_id, last_name, blog_url } = JSON.parse(
		event.body
	);
	try {
		// 2 stands for the maximum amount of requests allowed during the defined interval
		// rateLimit now returns a promise, let's await for it! (◕‿◕✿)
		await rateLimit(2, event.headers["client-ip"]);
	} catch (error) {
		return {
			statusCode: 429,
			body: JSON.stringify({
				status: 429,
				message: "Too Many Requests"
			})
		}; // Still returning a basic 429, but we could do anything ~
	}
	if (last_name) {
		return {
			statusCode: 200,
			body: JSON.stringify({
				status: 200,
				message: "Honeypot triggered"
			})
		};
	}

	const { data, error } = await supabase
		.from("comments")
		.insert({ name, comment, blog_id })
		.select();

	return {
		statusCode: 200,
		body: JSON.stringify({
			status: 200,
			message: data,
			error: error
		})
	};
};

You might notice that the heavy lifting is done by the beautiful lambda-rate-limiter package. This package allows us to rate limit our serverless function. In this case we are limiting the function to 2 requests per minute.

It’s worth giving credit to this blog post which covers the topic is significantly more detail than I’m going to go into here. Overall, this sort of solution is not perfect, but it should work well enough for my use case - preventing my blog comments from being spammed with viagra ads.

Displaying the comments

When displaying the comments I borrow a few patterns commonly used in conjunction with Web Components - namely the template html tag. Here’s what the HTML looks like:

<section class="comments-section">
	<h3 class="comments-title display-none">Comments:</h3>
	<h3 class="empty-comments display-none">No comments yet.</h3>
	<template id="comment-template">
		<li class="single-comment">
			<div class="comment-header">
				<p class="comment-date"></p>
				<p class="author">
					<span class="comment-author"></span> says...
				</p>
			</div>
			<p class="comment-body"></p>
		</li>
	</template>
	<ul id="comments-list"></ul>
</section>

The template tag is sort of like a storage container for HTML. It’s not visible to the user and it’s not rendered by the browser. It’s just there to be used by JavaScript when you need it. I much prefer this strategy to using a string literal in JavaScript or the definitively worst way: manually creating the DOM structure using document.createElement() 😱.

Using the template tag I can fetch the comments and display them like so:

const getComments = async () => {
	const commentsList = document.getElementById("comments-list");
	const commentTemplate = document.getElementById("comment-template");
	commentsList.innerHTML = "";

	const response = await fetch(
		`/.netlify/functions/get-comments?id=${id}`
	).then(response => response.json());

	response.message
		.sort(
			(a, b) =>
				Number(new Date(b.created_at)) -
				Number(new Date(a.created_at))
		)
		.forEach(comment => {
			const clone = commentTemplate.content.cloneNode(true);
			const author = clone.querySelector(".comment-author");
			const body = clone.querySelector(".comment-body");
			const date = clone.querySelector(".comment-date");
			author.textContent = comment.name;
			body.textContent = comment.comment;
			date.textContent = new Date(comment.created_at).toDateString();
			commentsList.appendChild(clone);
		});
};

Here I am:

  1. Fetching the comments from the serverless function.
  2. Sorting the comments by date.
  3. Cloning the template.
  4. Setting the author, body, and date.
  5. Appending the comment to the list.

The template tag is really useful here and helps to keep my JavaScript clean and readable. If I were to go a step further a might consider making the whole Comments component a web component, but I’ll leave that for another day.

Let me know in the comments below what you think of this approach, hopefully if you’re implementing a similar thing on your own blog these steps will be helpful.

Add a comment   ✍️