How I Built This Blog Using Nextjs App Router

Jun 16, 2025

software-development

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 since generateMetadata does not use dynamic API (such as cookies, 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 the prefers-color-scheme. For that you need to add this line to global.css: @custom-variant dark (&:where(.dark, .dark ));. Now dark:* utilities will be applied whenever the dark 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.