Filtering using React Server Components

March 29, 2024 (1mo ago)

One of the issues I had while setting up this very website was the need to filter posts, as one of the features I had in mind was tagging different posts with different technologies, and even separating my professional writing from my personal interests. With that in mind, a tag system seemed appropriate.

this template

Tagging posts

Adding tags to posts was pretty simple: just adding a tags field to the markdown frontmatter would work out pretty nicely. With that in place, some extra logic was added to parse front matter keys with more than one value (as a post may have any number of tags - including none at all).

First things first: we need to update the Metadata type definition to properly reflect our new frontmatter:

type Metadata = {
	title: string;
	publishedAt: string;
	summary: string;
	image?: string;
	tags?: string[]; // New!
};

Then, we need to update how we parse the metadata itself. Let's take a look at a frontmatter example so it's easier to visualize exactly what we're doing:

---
title: "Post title"
publishedAt: "2024-03-27"
summary: "Example post."
tags: "tag1, tag2"
---

Our base code already breaks up the frontmatter lines on separate entities for processing, so we can analyze each line separately:

// [...]
frontMatterLines.forEach((line) => {
  let [key, ...valueArr] = line.split(": ");
  let value = valueArr.join(": ").trim();
  value = value.replace(/^['"](.*)['"]$/, "$1"); // Remove quotes
  metadata[key.trim() as keyof Metadata] = value;
});

return { metadata: metadata as Metadata, content };

The main issue here is that our parser assume that we'll use a single value on each key - which isn't the case. We can get around this by checking if our value type might be an array. As our only array-like key will be tags and to prevent over engineering, let's check the current key against the key "tags" directly (it's trivial to replace this with an array of records relating the frontmatter keys to check with a post fields):

// [...]
frontMatterLines.forEach((line) => {
  let [key, ...valueArr] = line.split(": ");
  let value = valueArr.join(": ").trim();
  value = value.replace(/^['"](.*)['"]$/, "$1"); // Remove quotes
  if (key === "tags") {
    metadata.tags = value.split(",");
  } else {
    metadata[key.trim() as keyof Metadata] = value;
  }
});

return { metadata: metadata as Metadata, content };

Seems to work out nicely, but we still have some issues: maybe we don't want to have any tags at all. In that case, we'll need to set our stored tags to an empty array properly:

// [...]
frontMatterLines.forEach((line) => {
  let [key, ...valueArr] = line.split(": ");
  let value = valueArr.join(": ").trim();
  value = value.replace(/^['"](.*)['"]$/, "$1"); // Remove quotes
  if (key === "tags") {
    if (!value) {
      metadata.tags = [];
      return;
    }
    metadata.tags = value.split(",");
  } else {
    metadata[key.trim() as keyof Metadata] = value;
  }
});
return { metadata: metadata as Metadata, content };

There are two more issues that could arise in the future:

  1. As our input string is "tag1, tag2", our tag2 would start with an space, so we need to trim it;
  2. Our metadata[key.trim() as keyof Metadata] type is invalid, as value could either be a string or a string[] - which would enable invalid attributions. Let's fix that:
frontMatterLines.forEach((line) => {
  let [key, ...valueArr] = line.split(": ");
  let value = valueArr.join(": ").trim();
  value = value.replace(/^['"](.*)['"]$/, "$1"); // Remove quotes
  if (key === "tags") {
    if (!value) {
      metadata.tags = [];
      return;
    }
    metadata.tags = value.split(",").map((t) => t.trim());
  } else {
    metadata[key.trim() as Exclude<keyof Metadata, "tags">] = value;
  }
});

return { metadata: metadata as Metadata, content };

Ok, this should build our Typescript object correctly. Neat!

Rendering tags

Alright, so let's start by simply rendering the tags directly in the post list:

(This is pretty trivial, will write later)

Filtering tags

Here's where things get interesting. Let's start by getting all tags from all posts:

const PostList = () => {
  const allBlogs = getBlogPosts();
  const allTags = allBlogs.reduce((t, post) => {
    if (!post.metadata.tags || post.metadata.tags?.length === 0) {
      return t;
    }
    return [...t, ...post.metadata.tags];
  }, [] as string[]);
  // [...]
};

My original idea was to simply add a state to the post list, which would hold each tag that the user clicked, like this:

const PostList = () => {
    const [selectedTags, setSelectedTags] = useState<string[]>([]);
    // [...]

	const addTagToFilterList = (tag: string) => {
		if(selectedTags.includes(tag)) {
			return setSelectedTags([...selectedTags.filter(t => t === tag)]);
		}
		setSelectedTags([...selectedTags, tag]);
	}

	const renderTagSelectionSection = () =>
		allTags.map(tag => {
			<p onClick={()=> addTagToFilterList(tag)}>{tag}</p>
		});

	return (
		// [...]
		{renderTagSelectionSection()}
		// [...]
	)

}

So initially we iterate on the post tags, and render them (here, for simplicity, we're using simple paragraphs directly). When a user clicks, we check if they're on the filter list. If so, we remove them, and if not, we add them. Our blog post renderer can then apply a .filter() so we'll only render according to the selected posts - something like this:

const PostList = () => {
  const [selectedTags, setSelectedTags] = useState<string[]>([]);
  // [...]
  const shouldRenderPost = (postTags: string[]) =>
    postTags.some((r) => selectedTags.includes(r));

  return allPosts
    .filter((post) => shouldRenderPost(post.metadata.tags))
    .map((post) => renderPost(post));
};

An important part is shouldRenderPost, which checks if any element of the post's tags array exists on the selectedTags array. Ok, this seems to work, so what's the issue here? Simple:

Server Components

Basically, as we're rendering everything on the server, we can't use React hooks such as useState - and then I thought about it some more and it seemed pretty obvious - oops! What can we do now?

We could use client-side components for this, but I wanted to avoid them as this is basically a fully static website (and I really wanted to use server components for once 😊).

Well, turns out that there's a simple way to hold a kind of "state" on the server: URL Search parameters. We can build a custom URL that signals to our server which filters the user has selected, and the server can then render a page with these results already filtered out and send it to our client. Seems nice, let's get to it!

Desired user experience

Before we write anything, it'd nice to establish exactly what behavior is expected. When the user clicks on a tag:

  1. If it isn't selected, I want it to be selected and added to the list of filters
  2. If it is already selected, I want it to be deselected and removed from the list of filters As a consequence of server components, each time the user selects a tag, the page should refresh. Considering that Next.js caches its pages, this shouldn't be a huge issue.

Building a URL

Our URL should have a format like this: https://pietroluongo.com/blog?tags=tag1,tag2 And if no tags are selected, no tags parameter.

Let's start by defining a function that will help us build this URL. First of all, while we could hardcode our URL, let's get the current domain from the header list, so it's a tad more portable. Also, We can get the URL parameters as a function parameter directly in our base page component. We're left with this:

import { headers } from "next/headers";

interface Props {
	searchParams: {[key: string]: string};
}

const BlogPage = ({searchParams}: Props) => {
	// This is basically our "state"
	const selectedTagsToFilter = searchParams.tags?.split(",") ?? [];

	const buildTagFilterTargetURL(tag: string) => {
	    const headersList = headers();
	    const baseDomain = headersList.get("host") || "";
	    const finalURL = http://${baseDomain}/blog;
		return finalURL;
	}
	// [...]
}

Then, based on what we defined above, we can build our URL. I'll show you the code, and then go line-by-line:

const buildTagFilterTargetURL = (tag: string) => {
  const headersList = headers();
  const fullUrl = headersList.get("host") || "";
  const finalURL = http://${fullUrl}/blog;

  if (!selectedTagsToFilter || selectedTagsToFilter.length === 0)
    // (1)
    return ${finalURL}?tags=${tag};

  // (3)
  const tags = selectedTagsToFilter?.filter((t) => t !== tag);

  // (2)
  if (selectedTagsToFilter?.includes(tag)) {
    // (4)
    if (!tags || tags.length === 0) return finalURL;
    // (5)
    return ${finalURL}?tags=${tags.join(",")};
  }
  // (6)
  return ${finalURL}?tags=${[tags, tag].join(",")};
};

Firstly, in (1), we'll check if there are no tags to filter. If so, we'll want to add our filter to the URL, and return it immediately. In (2), we'll check if our tag is already selected. If not (6), we'll just iterate on all tags and build our URL with all of them. Otherwise, we'll want to get a list of all tags with our own tag excluded, and check if it's empty (4), so we're not left with something like https://pietroluongo.com/blog?tags=. If it is empty, we'll simply return the base URL. If not, we'll iterate on our filtered tags (5), removing the currently clicked tag from the list.

That's it! This function should always build valid URLs, and we can hook it up to our tag list as a <Link> tag instead of a invalid onClick() event handler (as these are also not supported on server components). Nice!

SEO issues?

While researching this topic, I stumbled upon some advice stating that URL parameters might be bad for SEO. https://www.semrush.com/blog/url-parameters/#handling-url-parameters-for-seo

(Will write later, needs more research)