Adding client side webmentions to my Next.js blog

Cory DransfeldtCory Dransfeldt
4 min read

The latest iteration of my website is built on Next.js, specifically Timothy Lin's wonderful Tailwind/Next.js starter blog.. I've modified it quite a bit, altering the color scheme, dropping components like analytics, comments and a few others while also building out some new pages (like my now page). As part of this process I wanted to add support for webmentions to the template, integrating mentions from Mastodon, Medium.com and other available sources.

To kick this off you'll need to log in and establish an account with webmention.io and Bridgy. The former provides you with a pair of meta tags that collect webmentions, the latter connects your site to social media1

Once you've added the appropriate tags from webmention.io, connected your desired accounts to Bridgy and received some mentions on these sites, you should be able to access said mentions via their API. For my purposes (and yours should you choose to take the same approach), this looks like the following Next.js API route:

import loadWebmentions from '@/lib/webmentions'

export default async function handler(req, res) {
    const target = req.query.target
    const response = await loadWebmentions(target)
    res.json(response)
}

You can see my mentions at the live route here.

I've elected to render mentions of my posts (boosts, in Mastodon's parlance), likes and comments. For boosts, I'm rendering the count, for likes I render the avatar and for mentions I render the comment in full. The component that handles this looks like the following:

import siteMetadata from '@/data/siteMetadata'
import { Heart, Rocket } from '@/components/icons'
import { Spin } from '@/components/Loading'
import { useRouter } from 'next/router'
import { useJson } from '@/hooks/useJson'
import Link from 'next/link'
import Image from 'next/image'
import { formatDate } from '@/utils/formatters'

const WebmentionsCore = () => {
    const { asPath } = useRouter()
    const { response, error } = useJson(`/api/webmentions?target=${siteMetadata.siteUrl}${asPath}`)
    const webmentions = response?.children
    const hasLikes =
        webmentions?.filter((mention) => mention['wm-property'] === 'like-of').length > 0
    const hasComments =
        webmentions?.filter((mention) => mention['wm-property'] === 'in-reply-to').length > 0
    const boostsCount = webmentions?.filter(
        (mention) =>
            mention['wm-property'] === 'repost-of' || mention['wm-property'] === 'mention-of'
    ).length
    const hasBoosts = boostsCount > 0
    const hasMention = hasLikes || hasComments || hasBoosts

    if (error) return null
    if (!response) return <Spin className="my-2 flex justify-center" />

    const Boosts = () => {
        return (
            <div className="flex flex-row items-center">
                <div className="mr-2 h-5 w-5">
                    <Rocket />
                </div>
                {` `}
                <span className="text-sm">{boostsCount}</span>
            </div>
        )
    }

    const Likes = () => (
        <>
            <div className="flex flex-row items-center">
                <div className="mr-2 h-5 w-5">
                    <Heart />
                </div>
                <ul className="ml-2 flex flex-row">
                    {webmentions?.map((mention) => {
                        if (mention['wm-property'] === 'like-of')
                            return (
                                <li key={mention['wm-id']} className="-ml-2">
                                    <Link
                                        href={mention.url}
                                        target="_blank"
                                        rel="noopener noreferrer"
                                    >
                                        <Image
                                            className="h-10 w-10 rounded-full border border-primary-500 dark:border-gray-500"
                                            src={mention.author.photo}
                                            alt={mention.author.name}
                                            width="40"
                                            height="40"
                                        />
                                    </Link>
                                </li>
                            )
                    })}
                </ul>
            </div>
        </>
    )

    const Comments = () => {
        return (
            <>
                {webmentions?.map((mention) => {
                    if (mention['wm-property'] === 'in-reply-to') {
                        return (
                            <Link
                                className="border-bottom flex flex-row items-center border-gray-100 pb-4"
                                key={mention['wm-id']}
                                href={mention.url}
                                target="_blank"
                                rel="noopener noreferrer"
                            >
                                <Image
                                    className="h-12 w-12 rounded-full border border-primary-500 dark:border-gray-500"
                                    src={mention.author.photo}
                                    alt={mention.author.name}
                                    width="48"
                                    height="48"
                                />
                                <div className="ml-3">
                                    <p className="text-sm">{mention.content?.text}</p>
                                    <p className="mt-1 text-xs">{formatDate(mention.published)}</p>
                                </div>
                            </Link>
                        )
                    }
                })}
            </>
        )
    }

    return (
        <>
            {hasMention ? (
                <div className="text-gray-500 dark:text-gray-100">
                    <h4 className="pt-3 text-xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 md:text-2xl md:leading-10 ">
                        Webmentions
                    </h4>
                    {hasBoosts ? (
                        <div className="pt-2 pb-4">
                            <Boosts />
                        </div>
                    ) : null}
                    {hasLikes ? (
                        <div className="pt-2 pb-4">
                            <Likes />
                        </div>
                    ) : null}
                    {hasComments ? (
                        <div className="pt-2 pb-4">
                            <Comments />
                        </div>
                    ) : null}
                </div>
            ) : null}
        </>
    )
}

export default WebmentionsCore

We derive the post URL from the fixed site URL in my site metadata, the URI from Next.js' router, concatenate them and pass them as the API path to my useJson hook, which wraps useSWR2:

import { useEffect, useState } from 'react'
import useSWR from 'swr'

export const useJson = (url: string, props?: any) => {
    const [response, setResponse] = useState<any>({})

    const fetcher = (url: string) =>
        fetch(url)
            .then((res) => res.json())
            .catch()
    const { data, error } = useSWR(url, fetcher, { fallbackData: props, refreshInterval: 30000 })

    useEffect(() => {
        setResponse(data)
    }, [data, setResponse])

    return {
        response,
        error,
    }
}

The target param narrows the returned mentions to those pertinent to the current post. Once we've received the appropriate response from the service, we evaluate the data to determine what types of mentions we have, construct JSX components to display them and conditionally render them based on the presence of the appropriate mention data.

The WebmentionsCore component is dynamically loaded into each post using the following parent component:

import dynamic from 'next/dynamic'
import { Spin } from '@/components/Loading'

const Webmentions = dynamic(() => import('@/components/webmentions/WebmentionsCore'), {
    ssr: false,
    loading: () => <Spin className="my-2 flex justify-center" />,
})

export default Webmentions

The final display looks like this:

Example webmentions

Footnotes

  1. For my purposes, social media is GitHub, Mastodon and Medium. I've used the rest at various points and no longer have an interest in them for myriad reasons.

  2. I've discussed this all a bit more in this post.

1
Subscribe to my newsletter

Read articles from Cory Dransfeldt directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Cory Dransfeldt
Cory Dransfeldt

Husband, dad, developer, music nerd.