Currently, my website mainly consists of the blog, which I created using Portfolio Starter Kit from Vercel. There were some changes I made and functionality I added (filtering of the posts and theme switcher) which I will explain in this post.
Blog Architecture
The way this blog works is there is a server component (app/components/server/posts.tsx
) which retrieves posts (which are MDX files) from
the filesystem and passes them as props to the client component (app/components/client/posts.tsx
). The client
component generates links for each post, which the user can click to navigate to the individual post page
(app/blog/[slug]/page.tsx
).
Filtering of the Posts
The starter kit did not include functionality for filtering blog posts. Since I wanted filtering based on tags, I added tag information for each post in the frontmatter (this is a block of metadata that appears at the very top of MDX file) and modified the function that parses the frontmatter to also read tags.
Initially, I implemented filtering on the server side. The server
component would filter the blog posts based on the URL search parameters (using getBlogPosts
function)
and forward the filtered results to the client component. The user could
update the filter values using a select component (react-select
), which would
update the URL with the new filter values. This triggered a re-render of
the server component, which would then filter the posts again using the
updated query parameters and pass the filtered results back to the client component.
Why I Switched to Client-Side Filtering
However, I realized that this approach could potentially cause multiple server function invocations whenever the user interacted with the filters (each time a filter changed, the server would have to perform filtering). While this is usually fine, depending on your hosting plan, you might want to limit the number of server function invocations. Currently I am using Vercel Hobby plan, and they have limitations on such function invocations.
To address this, I refactored the client component to handle filtering locally using React state. In this updated version, the server component simply retrieves all blog posts and sends them to the client component (since we are using these posts to generate links, we are not sending full versions of the posts). This allows the server component to be statically generated, avoiding server function invocation when the user filters posts.
Keeping URL Parameters in Sync with Filter UI
Even though filtering now happens on the client side, I still wanted our filter
to support URL parameters. I decided to use the native History API (history.pushState
) to update the URL
with new filter values, instead of using router.push
from next/navigation
.
The main reason for this is that router.push
triggers a server round-trip before updating the page.
This means the server must respond — which may involve a server component executing its logic — and only then
does React update the URL and display the new page. This can create a poor user experience when a client component’s UI depends on values returned by the server in response
to the new URL, since changes won’t be visible immediately.
There is hook useOptimistic
which can help mitigate issues mentioned in the previous paragraph.
This post
offers a great exploration of how to use useOptimistic
to build URL-driven filter panels which respond quickly to user actions.
Actually my initial server side filtering approach was using useOptimistic
based implementation of the client component, if you are interested in this,
you can check the server-side-filtering
branch.
Finally, we also had to keep track of URL changes to update our filters, which I implemented by listening to the popstate
event.
The popstate
event will be triggered by doing a browser action such as a click on the back or forward button (or calling history.back
or history.forward
in JavaScript).
This event does not get triggered by history.pushState
or history.replaceState
calls but this is not a problem in our case.
Individual Blog Post Page
The file responsible for rendering an individual blog post is located
at:
app/blog/[slug]/page.tsx
.
This file was already part of the starter kit; I have only made minimal
changes to it. Below is a brief explanation of some existing
functionality within it:
generateStaticParams
is used to ensure that each blog post is statically generated at build time.generateMetadata
can be used to dynamically generate metadata of a page. In our case since the page is generated at build and sincegenerateMetadata
does not use dynamic API (such ascookies
,headers
, etc.), metadata is also generated at build time.- The page includes structured data in the form of JSON-LD which improves search engine understanding.
Adding Theme Switcher
The starter kit already had theme support in the sense that it was using
tailwind classes like dark:text-white
- which meant it would work with
the user’s browser or system theme (it would detect theme using
prefers-color-scheme
CSS media feature). But since I wanted to have a
manual theme switcher, I had to add such component
(app/components/client/theme-toggle.tsx
).
There were certain things I had to do to make the manual theme switching work:
- you need a UI switch component (for this I used
react-switch
), - you need to take care of common theme management tasks: such as persisting the selected theme in local storage, applying the appropriate dark or light class to the HTML element, etc. (for this I used
next-themes
) - As I mentioned currently the site would detect theme using the
prefers-color-scheme
media feature. When you want to allow users to toggle theme manually from the site, you want the dark theme to be driven using a CSS selector instead of theprefers-color-scheme
. For that you need to add this line toglobal.css
:@custom-variant dark (&:where(.dark, .dark ));
. Nowdark:*
utilities will be applied whenever thedark
class is present earlier in the HTML tree. - the starter kit comes with packages
tailwindcss
and@tailwindcss/postcss
and both had version 4.0.0-alpha.13. I had to upgrade them to versions 4.1.8 otherwise the manual theme switching was not working.
Other Changes
The starter kit also had a sitemap (app/sitemap.ts
), a file which has information to help search engines crawl the site and an RSS route handler (app/rss/route.ts
), which provides an RSS feed (XML file for distributing updates to subscribers). The docs say sitemap.js
is a special route handler that is cached by default (unless it uses a dynamic API or dynamic config option).
RSS route however, is normal route which is not cached by default, so I added export const dynamic = "force-static"
config option to it to avoid possible function invocations because it relies on getBlogPosts
function.
I had to add custom component for pre
tag in the components
object (app/components/server/mdx.tsx
).
That object lists custom components that should be used in place of one of the default components that MDX automatically renders for different HTML tags.
I had to make this addition otherwise MDX library was not differentiating between inline and block level code.
The starter code also uses a code syntax highlighter (sugar-high
) to highlight code blocks.
It had some overrides in the global.css
for some tokens of the code syntax highlighter for the dark theme,
but it was using the @media (prefers-color-scheme: dark)
selector. Since we switched to manual
theme management, I added .dark
selector for the overrides instead.
Source Code
You can find the source code here.