Iāve been feeling blind when writing on my own website after things like Medium because I havenāt added a comments functionality. They are quite useful when blogging, you can have feedback, better or worse, points on which to improve, and just see that your blog is alive.
However, working with comments is certainly not an easy task - you must design and code a complete full-blown feature, including reactions, replies, and most importantly moderation. I couldāve added a simple textarea with a button and some challenge to comment, but it is easily crackable in the age of AI.
Iāve already seen a well-designed comment functionality in Tk Todoās blog, and I remembered that it was something GitHub-powered. I did a fast google search and found out about utterances, which is a very simple to implement <script>
tag that works based on your public GitHub repo.
It works by utilizing issues (which is creates automatically, if needed) on which the users can chat, reply to each other, react with emoji, add code block and report someone malicious if theyād like. It also gates commenting behind GitHub OAuth
, which should eliminate spam completely. It even features multiple themes, two of which Iāve employed on my blog.
To set it up, we can pass through a simple form on their website, or just configure everything ourselves. Thereās no huge documentation, so the builder was helpful for me to get started easily. Iāve chosen issue-term
(determinator of issue to be linked with a script block to comment in) to be title of my blogās page (which I will probably change later as it is not really a constant variable), although thereās a vast majority of options, including even og:meta
parsing.
You can also create issues manually and link them by either issue number or a word in the title. It is sad that itās not possible to link issues by tags, but thatās quite a rough functionality to implement, because you can add multiple of the same tags on many issues. Hereās my final script from the builder:
1<script src="https://utteranc.es/client.js"2 repo="[ENTER REPO HERE]"3 issue-term="title"4 label="blog"5 theme="github-light"6 crossorigin="anonymous"7 async>8</script>
As my blog is a ātemplateā for others to fork too (although not a big one!), Iāve added a global UTTERANCES_CONFIG
, which you can omit and use the object straight up later. Here, Iāve chosen a label blog
to mark all auto-created issues with it, and, as mentioned previously, issue-term
is title
. repo
is in owner/repoName
format, which Iāve also included directly without the use of environment variables, but you can do whatever.
1export const UTTERANCES_CONFIG = {2 repo: "Serpentarius13/personal-blog",3 label: "blog",4 ["issue-term"]: "title",5};
The functionality I needed was pretty dynamic, so I have decided to not add a static script element to HTML somewhere in my Astro blog page. Instead Iāve created an UtterancesScript.astro
file, which is a simple Astro component, to incapsulate the whole logic in there.
It first appends HTML markup stored in loader
variable to render a little loader before comments appear, and then prepares a script
element to be attached to utterances-container
. Iām attaching listener with addEventListener
(which I hadnāt even know you could use like that!) to check for an event that utterances
sends when it loads and I set the isLoaded
flag to true
. A MutationObserver
later syncs the theme based on the theme set on websiteās documentElement
now, next theme is retrieved with getNextTheme
function, and swapTheme
sends a message to utterances
ās client.js
script.
Hereās a full version this component.
1---2import { UTTERANCES_CONFIG } from "config";3---4
5<script is:inline define:vars={{ UTTERANCES_CONFIG }}>6// mark script as is:inline to not be processed by Astro7// pass UTTERANCES_CONFIG with define:vars8// you can specify options right inside this script if you9// don't want to move them out10
11// my darkThemes map object to check whether current theme's dark12 const darkThemes = {13 night: "night",14 luxury: "luxury",15 };16
17// loader markup18const loader = `19 <div class="flex p-2 rounded-xl border border-primary items-center gap-2 mx-auto w-fit mt-4" id="utterances-spinner">20 <span class="loading loading-infinity loading-xl">21 </span>22 <span class="text-xl">23 Loading comments...24 </span>25 </div>`;26
27// utterances url for reuse28 const UTTERANCES_URL = "https://utteranc.es";29
30 let isLoaded = false;31// gets next comments theme based on current website theme32 const getNextTheme = () => {33 const theme = document.documentElement.dataset.theme || "";34 return theme in darkThemes ? "gruvbox-dark" : "boxy-light";35 };36
37// loader function to reuse twice38 const load = () => {39 // I've added a div with id of utterances-container right40 // below my post's content. You can put it anywhere41 const container = document.getElementById("utterances-container");42 const loaderWrapper = document.getElementById("utterances-loader");43 // check everything is loaded44 if (!container) {45 console.log("no utterances container found");46 return;47 }48 if (!loaderWrapper) {49 console.log("no utterances loaderWrapper found");50 return;51 }52
53 // toggle loader54 loaderWrapper.innerHTML = loader;55
56 // extract theme57 const theme = document.documentElement.dataset.theme || "";58
59 // create script and map over attributes.60 // it is important to use "setAttribute" instead61 // of assigning because assigning won't work62 // on html elements63 const script = document.createElement("script");64
65 const attributes = {66 theme: getNextTheme(),67 ...UTTERANCES_CONFIG,68 };69
70 for (const [key, value] of Object.entries(attributes)) {71 script.setAttribute(key, value);72 }73
74 // passing constant parameters75 script.src = `${UTTERANCES_URL}/client.js`;76 script.crossOrigin = "anonymous";77 script.async = true;78
79 script.onerror = (e) => console.log("error loading utterances:", e);80
81 // check for load82 addEventListener("message", (event) => {83 if (event.origin !== UTTERANCES_URL) return;84 isLoaded = true;85 loaderWrapper.innerHTML = "";86 });87
88 // append a script to container to be ran89 container.append(script);90 };91
92 // we retrieve iframe's contentWindow93 // to postMessage there a "set-theme" event94 const swapTheme = () => {95 const message = {96 type: "set-theme",97 theme: getNextTheme(),98 };99 const utterances = document100 .getElementById("utterances-container")101 ?.querySelector("iframe")?.contentWindow;102 utterances?.postMessage(message, UTTERANCES_URL);103 };104
105 const observer = new MutationObserver((records) => {106 if (!isLoaded) return;107
108 for (const record of records) {109 if (record.attributeName !== "data-theme") return;110
111 swapTheme();112 }113 });114
115 observer.observe(document.documentElement, {116 attributes: true,117 attributeFilter: ["data-theme"],118 });119
120 // load once on running the script121 load();122</script>
Hereās a simplified version for you to copy and paste:
1---2
3---4
5<script is:inline>6 const darkThemes = {7 night: "night",8 luxury: "luxury",9 };10 const UTTERANCES_URL = "https://utteranc.es";11
12 let isLoaded = false;13 const getNextTheme = () => {14 const theme = document.documentElement.dataset.theme || "";15 return theme in darkThemes ? "gruvbox-dark" : "boxy-light";16 };17
18 const load = () => {19 const container = document.getElementById("utterances-container");20 const loaderWrapper = document.getElementById("utterances-loader");21 if (!container) {22 console.log("no utterances container found");23 return;24 }25 if (!loaderWrapper) {26 console.log("no utterances loaderWrapper found");27 return;28 }29
30 loaderWrapper.innerHTML = loader;31
32 const script = document.createElement("script");33
34 const attributes = {35 theme: getNextTheme(),36 ...UTTERANCES_CONFIG,37 };38
39 for (const [key, value] of Object.entries(attributes)) {40 script.setAttribute(key, value);41 }42
43 script.src = `${UTTERANCES_URL}/client.js`;44 script.crossOrigin = "anonymous";45 script.async = true;46
47 script.onerror = (e) => console.log("error loading utterances:", e);48
49 addEventListener("message", (event) => {50 if (event.origin !== UTTERANCES_URL) return;51 isLoaded = true;52 loaderWrapper.innerHTML = "";53 });54
55 container.append(script);56 };57
58 const swapTheme = () => {59 const message = {60 type: "set-theme",61 theme: getNextTheme(),62 };63 const utterances = document64 .getElementById("utterances-container")65 ?.querySelector("iframe")?.contentWindow;66 utterances?.postMessage(message, UTTERANCES_URL);67 };68
69 const observer = new MutationObserver((records) => {70 if (!isLoaded) return;71
72 for (const record of records) {73 if (record.attributeName !== "data-theme") return;74
75 swapTheme();76 }77 });78
79 observer.observe(document.documentElement, {80 attributes: true,81 attributeFilter: ["data-theme"],82 });83
84 load();85</script>
Markup below the post looks like this:
1<div>2 <div id="utterances-loader"></div>3 <div id="utterances-container"></div>4</div>
As utterances
creates issues with a template that I didnāt like (a title with a url to follow in contents - yes I wanted a link with a name), Iāve also decided to create issues for my four blog posts myself in this manner:

If you have a lot of articles, you can use GitHub API
to do this automatically on a large dataset retrieved from your database or Content Collections
. You can run it every time your application deploys to ensure that every article has its issue to comment.
Thatās all! Thanks for reading!