
Tech Stack Review: Zakiego-v4
M. Zakyuddin Munziri
@zakiego
Originally written in Bahasa Indonesia.

M. Zakyuddin Munziri
@zakiego
Originally written in Bahasa Indonesia.

Fast pipelines don't eliminate shipping fear. Confidence comes from safe rollbacks, feature flags, and systems that behave predictably when things go wrong.

Debugging changed when I stopped reading logs manually and started using AI agents to correlate errors across observability data - faster root cause, fewer dead ends.

Framework debates waste team energy. Most frameworks are good enough, focus on engineering process, document tradeoffs, and stop treating tools like identity.

This site is my 4th personal website (https://v4.zakiego.com). Through this article, I will briefly review the technologies used to build this website.
For Tailwind UI users, this site should look familiar, because indeed, this site was built using a template from Tailwind UI. The template name is Spotlight (https://tailwindui.com/templates/spotlight). For this reason, this project's repository will remain private.
Side note, if it's someone else's work, mention who the original creator is. Credibility is about self-respect.
Although using a template, there were many things I needed to customize, which took about two weeks.

Why use a template?
The main reason is lack of time, as daily time is already drained by work. Of course, if I had to build from scratch, I could only use weekends.
Unfortunately, I've dedicated weekends for rest and exploring new things. In the end, a template was the solution. I wanted to focus more on business logic, while leaving the appearance to Tailwind UI. 😉
Some might ask, is Tailwind UI worth it? How much does it cost and how do you buy it? I'll discuss that another time. Please remind me. 😜

This library is almost certainly still unfamiliar, because it's still very young. I decided to use Keystatic as a CMS because:
Based on these 2 reasons, Keystatic solves them by:

As you can see, one content can contain two files: json for properties, and mdoc for the content body (--usually in md format, but for some reason Keystatic uses mdoc).
// index.json
{
"title": "2021 Year in Review",
"draft": false,
"image": "/images/articles/2021/image.webp",
"category": "contemplation",
"date": "2021-12-30",
"summary": "One word that best describes this year and the word that keeps echoing in my head. In this article, I will try to summarize what I learned throughout the year."
}
# content.mdoc
**Growing.**
One word that best describes this year and the word that keeps echoing in my head. In this article, I will try to summarize what I learned throughout the year. The arrangement is not based on chronological order, but starting from what I consider most important, then onwards.

Besides these two things, we can also edit content in production after deployment. Later, the edits will be pushed to our GitHub repository (– this process is handled by Keystatic). Of course, it takes time to rebuild.
Nothing is perfect, Keystatic also has its drawbacks. So far, all content handled by Keystatic can only be fetched during the build process. This means the web will be fully static. Or if using Next.js, you can only use getStaticProps, without revalidate, and certainly not getServerSideProps.
But for me, that trade-off is not a problem, because a blog doesn't have to update that quickly. Build time only takes about one minute.
It could be said that using tRPC on this website is actually over-engineering. But, that's fine, because personal projects are meant for exploring many things. 😋
Without tRPC, the function to read data from Keystatic could be made simple, requiring only one key function:
const keystaticReader = createReader(process.cwd(), keystaticConfig);
The keystaticReader function can then be used directly.
But, considering the many data restructurings, I chose to use tRPC so all that processing is done in one place. For example, here are two routers below.
const keystaticRouter = createTRPCRouter({
profile: publicProcedure.query(async () => {
const data = await keystaticReader.singletons.profile.read();
const parsed = keystaticValidation.profile.parse(data);
return parsed;
}),
highlightArticles: publicProcedure.query(async () => {
const { articles } =
await keystaticReader.singletons.highlightArticles.readOrThrow();
const getArticle = articles.map(async (slug) => {
const data = await keystaticReader.collections.articles.readOrThrow(
slug as string,
);
const content = await data.content();
const render = {
...data,
slug,
content,
};
const parsed = keystaticValidation.articles.parse(render);
return parsed;
});
}),
});
The route from keystaticRouter.profile is still simple, because it only contains reading and validation processes using Zod. By the way, here is the validation schema using Zod on keystaticValidation.profile:
profile: z.object({
avatar: z.string(),
name: z.string(),
headline: z.string(),
biography: z.string(),
images: z.array(
z.object({
title: z.string(),
image: z.string(),
}),
),
}),
Moving on, keystaticRouter.highlightArticles is more complex, because it has two functions: getting the list of highlighted articles and getting the articles themselves. After getting the articles, there's a process of extracting content, restructuring data, and finally validation.
Actually, Keystatic itself already has validation features, but I feel Keystatic's validation is not strict enough, so I decided to re-validate with Zod. This validation is done so that when there's missing data, for example an unfilled date, I can immediately get an error warning.
That validation schema is placed in one file as a single source of truth.

To close, please allow me to quote this tweet from @azamuddin91 🤣.