Skip to content
Writing

7 min read

How I built my blog with NextJs

A tour of how this blog is built today, one piece at a time: Next.js, Tailwind, TanStack Markdown and Highlight, Sandpack and Hygraph.

  • Next.js
  • Tanstack/Markdown

Introduction

This is the third version of my blog, and I rebuilt it from scratch. In this post I'll walk through how it works now: what I chose, why I chose it, and what I learned along the way. I'll also point you to the sites that inspired me.

This post has been rewritten

The first version of this post described an older setup with mdx-bundler, FaunaDB and Graph CMS. All of that has changed, so everything below describes the blog you're reading right now.

The stack at a glance

Next.js

Posts are fetched on the server and cached. Every request to Hygraph is tagged and revalidates hourly, so a post is served as a fast static page but still stays fresh:

lib/hygraph.ts
const res = await fetch(endpoint, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ query, variables }),
  next: { tags: [HYGRAPH_TAG], revalidate: 3600 },
});

I don't want to wait an hour after hitting publish, though. Hygraph calls a webhook when I publish, and the webhook clears the cache tag:

app/api/revalidate/route.ts
export async function POST(request: NextRequest) {
  const secret = process.env.HYGRAPH_REVALIDATE_SECRET;
  const provided = request.headers.get('x-revalidate-secret');

  if (!secret || provided !== secret) {
    return Response.json({ revalidated: false }, { status: 401 });
  }

  revalidateTag(HYGRAPH_TAG, { expire: 0 });
  return Response.json({ revalidated: true });
}

Preview works the same way. A second route turns on Next.js draft mode, so I can read an unpublished post on the real site before it goes live.

Tailwind CSS and the design system

Styling is Tailwind CSS v4. Instead of a long config file, the colours live in CSS variables, and Tailwind reads them through a theme block. Dark mode swaps the same variables:

app/globals.css
:root {
  --bg: #ffffff;
  --fg: #171717;
  --muted: #666666;
  --faint: #8f8f8f;
  --grid: #d4d4d8;
  --accent: #0072f5;
}

.dark {
  --bg: #000000;
  --fg: #ededed;
  --muted: #a1a1a1;
  --faint: #6f6f6f;
  --grid: #262626;
  --accent: #52a8ff;
}

The design is deliberately quiet: black and white, hairline borders and a lot of dotted lines. That dotted look is one small CSS utility, reused for the rails on either side of the page, the section dividers and the timeline lists:

app/globals.css
@utility dash-x {
  background-image: linear-gradient(to right, var(--dash-color, var(--grid)) 50%, transparent 0);
  background-size: 6px 1px;
  background-repeat: repeat-x;
}

Defining it once means every dotted line in the site has the same rhythm. If I change it, they all change together.

Markdown

My posts used to be MDX, compiled with mdx-bundler. Now they're plain Markdown, parsed by TanStack Markdown. It's a small parser that produces a serialisable syntax tree, and it renders it to React on the server. Because the tree is plain data, there's nothing to compile at request time.

What made me switch was how little I was using MDX for. I mostly wanted callouts, code and the occasional interactive demo, and TanStack Markdown covers all three through extensions:

lib/markdown.ts
export const markdownExtensions: MarkdownExtension[] = [
  calloutsExtension(),
  commentComponentsExtension(),
  headingCollectionExtension(),
];

export function parseContent(source: string): MarkdownDocument {
  return parseMarkdown(normalizeLegacyMdx(source), { extensions: markdownExtensions });
}

Here's what each extension gives me:

  • Callouts turn GitHub-style blockquotes such as > [!TIP] into styled boxes, like the ones on this page.
  • Comment components let me embed custom components with an HTML comment, for example a live playground or a figure.
  • Heading collection gathers the headings so I can build the table of contents.

Rendering is one call. The components map is where I swap in my own elements, such as a code block with a copy button:

components/markdown/markdown.tsx
{renderMarkdownReact(document, {
  extensions: markdownExtensions,
  highlighter: codeHighlighter,
  codeLineNumbers: true,
  headingAnchors: { content: '#', className: 'heading-anchor' },
  components,
})}

Older posts still work

Posts written for MDX still render, because a small function rewrites the few components I used into their Markdown equivalents before parsing. It keeps the old posts alive without keeping MDX around.

Code snippets

The blog has two kinds of code: static snippets and live playgrounds.

Static snippets

TanStack Highlight tokenises the code on the server and gives each piece a class, such as th-keyword or th-string. My CSS then colours those classes with the same variables as the rest of the site. There's no theme file, so light and dark mode work from a single tree of HTML.

The fence line carries the options. A title adds a file header, and a list of numbers in braces highlights lines:

md
~~~tsx title="components/counter.tsx" {4-6}
// code goes here
~~~

Every code block on this page is rendered that way.

Live playgrounds

For anything interactive I use Sandpack, which runs a real React app in the browser. I drop a comment component into the post:

md
<!-- ::sandpack title="Counter" -->

The files come from a files field on the post in Hygraph, so the demo's code lives with the post instead of inside it. Try it, edit the code and watch the preview change:

Hygraph

Hygraph, formerly Graph CMS, is a headless CMS with a GraphQL API. It stores every post and snippet, so writing never touches the codebase.

To set it up, I created a project, added a Post model in the schema section and then wrote the content in the content section. A post has these fields:

  • slug, title and description
  • content, which holds the Markdown
  • tags, a list of tags
  • files, JSON for the Sandpack demos

Then I copied the Content API URL from the endpoint settings into an environment variable. If the API isn't public, an access token goes in another variable. From there the site reads posts with plain fetch calls, as in the snippet at the top of this post.

Everything else

A few smaller pieces round it out:

  • Open graph images are generated with Next.js's built-in ImageResponse, using one shared card design for every page. I wrote about the technique in How to generate dynamic open graph images.
  • RSS and the sitemap are route files that read from Hygraph, so they update themselves whenever I publish.
  • Analytics run through PostHog. I proxy its requests through my own domain so ad blockers don't drop them.
  • Motion handles the small details, like the dot that follows your cursor down a timeline and the floating contents pill you can see on this page.
  • Tooling is pnpm with oxlint and oxfmt, and a husky hook that lints and formats every commit.

Inspiration

These are the sites that inspired me to build my own blog. I'd recommend all of them:

Wrapping up

That's the whole blog. I hope it gives you the push to build your own. If you have questions or ideas, find me on X at @jana__sundar or email me at mailtojana23@gmail.com. Until next time, happy coding!