Adding client side webmentions to my Next.js blog
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 useSWR
2:
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:
Footnotes
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.