Developing a Portfolio Blog
In order to document my projects and demonstrate my portfolio I have created a blog using Strapi, NextJS, Node, and Meilisearch.
Published: 12/25/2025
As an aspiring software engineer, it is important to make your achievements and experiments accessible to the public. For me, I realized that the most accessible, and demonstrative medium for that would be a blog. It made more sense for me to turn the blog itself into a piece of that portfolio, so in this article I’ll be going over the decisions, problems, and compromises I had to make while developing it.
Software Used
- NodeJs
- NextJs
- Strapi
- Meilisearch
Pull data from Strapi
Once I have a Github repo set up, the first goal is to populate Strapi with some data, and query the api and pull that data from NextJs. This guide on the Strapi website came in great handy in order to get my first article pulled into NextJs, particularly the end. Essentially, the idea is to create an Article object, create an api query URL, and fetch the articles and return them to the page as a map to iterate over and render.
//Fetch Articles
import Article from "./article";
const STRAPI_URL = process.env.STRAPI_URL ?? "http://localhost:1337";
export default async function getArticles(): Promise<Article[]> {
const res = await fetch(
`${STRAPI_URL}/api/articles?populate=*&sort=publishedAt:desc`,
{
cache: "no-store",
},
);
const json = await res.json();
const data = json.data ?? [];
return data.map((item: any) => {
const a = item.attributes ?? item;
return {
title: a.title,
description: a.description,
slug: a.slug,
author: a.author,
category: a.category,
body: a.body,
publishedAt: a.publishedAt,
} satisfies Article;
});
}
//Render Articles
<div className="px-2 space-y-3 min-w-full">
{articles.map((article) => (
<article key={article.slug} className="w-full">
<div className="w-full rounded-lg bg-zinc-900/60 border border-zinc-800/70 px-4 py-2 shadow-sm">
<Link href={`/${article.slug}`}>
<div>
<h3 className="capitalize text-3xl font-bold">
{article.title}
</h3>
</div>
<div>
<p className="text-lg text-gray-500">{article.description}</p>
</div>
<div>
<p className="text-sm text-gray-300">
Published: {formatDateUTC(article.publishedAt)}
</p>
</div>
</Link>
</div>
</article>
))}
</div>
Render markdown articles
After I had all the articles pulled in and rendering in NextJs, I went ahead and made a layout, a navigation bar and an about page using tailwind, once that was complete, I needed a dynamic route to each article. In this route, I would fetch the articles and filter them by the article slug. Once I had the article, I realized that the articles were to be written in markdown, and rendered using Next and html. Doing some research, I discovered markdown-it. This would convert the markdown for me and once cleaned up with DOMPurify I could write the Next code that would render the article. Once I had the article rendering I discovered I would need tailwind-typography in order to style individual markdown elements before they were rendered.
//Fetch single article
async function getArticle(slug: string) {
const baseUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:1337";
const path = "/api/articles";
const url = new URL(path, baseUrl);
url.search = qs.stringify({
populate: "*",
filters: {
slug: {
$eq: slug,
},
},
});
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to fetch article");
const data = await res.json();
const article = data?.data[0] ?? null;
return article;
}
//Configure markdown-it and DOMPurify
let pure = "";
const slug = await params;
if (!slug) notFound();
const article = await getArticle(slug.slug);
if (!article) notFound();
const md = markdownit();
if (!article.body) {
notFound();
}
pure = DOMPurify.sanitize(md.render(article.body));
//Render article
<div className="px-2 space-y-3 min-w-full">
<article key={article.title} id="content" className="w-full">
<div className="w-full rounded-lg bg-zinc-900/60 border border-zinc-800/70 px-4 py-2 shadow-sm g-gray-700/10">
<div>
<h3 className="text-3xl font-bold capitalize">{article.title}</h3>
</div>
<div>
<p className="text-lg text-gray-500">{article.description}</p>
</div>
<div>
<p className="text-sm mb-4">
Published: {formatDate(article.publishedAt)}
</p>
</div>
<div
className="max-w-5xl prose"
dangerouslySetInnerHTML={{ __html: pure }}
/>
</div>
</article>
</div>
Navigation features
Once I had the pages and layout rendering in a way that looked appealing, I had to implement some navigation features. I settled on 4 ways to navigate the site. A go to top button, a table of contents, an archive navigation, and a search bar.
Go To Top
The go to top button was simple, I had to implement a useScrollPosition hook to keep the scroll position up to date, then render the button only when the y axis was greater than 200, then when the button was clicked, reset the scroll position to 0.
//useScrollPosition
import { useEffect, useState } from "react";
const useScrollPosition = () => {
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const updatePosition = () => {
setScrollPosition({ x: window.scrollX, y: window.scrollY });
};
window.addEventListener("scroll", updatePosition);
updatePosition();
return () => window.removeEventListener("scroll", updatePosition);
}, []);
return scrollPosition;
};
export default useScrollPosition;
//Go to top component
"use client";
import useScrollPosition from "../hooks/useScrollPosition";
function resetScroll() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
const scrollToTop = () => {
const scrollPosition = useScrollPosition();
return (
<div className="">
{scrollPosition.y > 200 && (
<div className="hidden lg:block fixed inset-x-0 bottom-10 z-50 pointer-events-none">
<div className="mx-auto max-w-7xl px-4">
<div className="grid lg:grid-cols-[minmax(0,16rem)_minmax(0,46rem)_minmax(0,16rem)]">
<div />
<div />
<div className="flex justify-start lg:pl-8 md:pl-0">
<button
type="button"
onClick={resetScroll}
className="pointer-events-auto rounded-full border border-zinc-700 bg-zinc-900/50 px-4 py-2 shadow hover:bg-zinc-700 transition text-2xl"
aria-label="Scroll to top"
>
↑
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default scrollToTop;
Table of Contents
The table of contents was simple as well, the main concept here is to get all the header tags, slugify their titles by replacing white space with dashes, give them each a unique id based on that slug, and render the list.
//Slugify headers and set id
const content = document.getElementById("content");
if (!content) {
setItems([]);
return;
}
const headers = Array.from(content.getElementsByTagName("h1"));
const next = headers
.map((h) => {
const text = (h.textContent ?? "").trim();
if (!text) return null;
const id = text.replaceAll(" ", "-");
h.id = id;
return { id, text };
})
.filter((v) => v !== null);
//Render ToC
<div className="min-w-0 rounded-lg bg-zinc-900/50 border border-zinc-800/70 p-2">
<ol className="list-decimal list-inside">
{items.map((item) => (
<li key={item.id} className="min-w-0">
<a
className="min-w-0 break-words inline capitalize hover:text-gray-400"
href={`#${item.id}`}
>
{item.text}
</a>
</li>
))}
</ol>
</div>
Archive navigation
For the archive navigation, the solution was a little more complex. The idea was to compile all the articles into an object that contained the years/months the articles were published and a link to that article. Then, if a year has 9 or more articles display the months inside that yearly drop down, and, as always, render the component.
//Create articles list
type MonthNav = { total: number; articles: Article[] };
type YearNav = {
total: number;
hasMonths: boolean;
articles: Article[];
months: Record<number, MonthNav>;
};
type YearArchiveNav = Record<number, YearNav>;
function monthName(m: number) {
return new Date(Date.UTC(2000, m, 1)).toLocaleString("en-US", {
month: "long",
timeZone: "UTC",
});
}
function getArticlesByYear(articles: Article[]): YearArchiveNav {
const output: YearArchiveNav = {};
for (const v of articles) {
const d = new Date(v.publishedAt);
const year = d.getUTCFullYear();
const month = d.getUTCMonth();
if (!output[year])
output[year] = { total: 0, hasMonths: false, articles: [], months: {} };
output[year].total++;
output[year].articles.push(v);
if (!output[year].months[month])
output[year].months[month] = { total: 0, articles: [] };
output[year].months[month].total++;
output[year].months[month].articles.push(v);
if (output[year].total > 9) output[year].hasMonths = true;
}
return output;
}
Search
Implementing search, for me was probably the most interesting part of developing the project. Not only was it something I haven’t done before, the workflow of implementing it mirrored a workflow that I am deeply familiar with. After doing a bit of research, I decided to go with meilisearch. To get it working, I had to install the meilisearch plugin in strapi then give it access to the articles collection. Then, install the npm package into the front end. After that, I had to get the program running on my pc itself and begin hosting a session. Thankfully, my package manager already had it, so that was also rather simple. Afterwards, I had to implement the search into the front end, the search bar would take the submitted contents and route the user to “/search?q=<query>”. This blog post was a great demonstration of how to implement meilisearch into Next, which led me to this code snippet which returns an object containing the 20 most relevant search results. From there, it was as simple as rendering the search results.
const { q } = await searchParams;
const query = q ?? "";
const hits: any[] = await (async () => {
if (!query) return [];
const client = new MeiliSearch({
host: process.env.MEILI_HOST ?? "http://localhost:7700",
apiKey: process.env.MEILI_MASTER_KEY ?? "aSampleMasterKey", // server-only, NOT NEXT_PUBLIC
});
const res = await client.index("article").search(q, {
limit: 20,
attributesToCrop: ["body:20", "description:20"],
cropMarker: "…",
attributesToHighlight: ["body", "description", "title"],
highlightPreTag: "<mark>",
highlightPostTag: "</mark>",
attributesToSearchOn: ["title", "body", "description"],
});
return res.hits as any[];
})();