Migrating My Blog from Jekyll to Next.js and Tailwind CSS
A modern web development workflow for blogging
After a few years of running my blog on Jekyll, I decided it was time for an upgrade. Jekyll served me well, but as my needs evolved, I found myself wanting more flexibility, better performance, and a more modern development workflow. Enter Next.js, Tailwind CSS, and a host of other modern web development tools that have transformed my blogging experience. Here's a rundown of the migration process and the benefits I’ve enjoyed since making the switch.
Jekyll is a static site generator that is simple, reliable, and supported by GitHub Pages. However, it has its limitations:
- Performance: While Jekyll is fast for simple blogs, it can become sluggish as your content grows.
- Customization: Advanced customization often requires diving into Liquid templates and custom plugins, which can be cumbersome.
- Modern Workflow: Jekyll can refresh the browser with
--livereload, but every change still goes through a full site rebuild; there is no hot module replacement, and anything stateful on the page is left entirely to you.
I decided to use a more "modern" stack with Next.js, which is a React-based framework that provides the following features:
- Server-Side Rendering (SSR) and Static Site Generation (SSG): Next.js allows you to choose between SSR and SSG, optimizing performance and SEO.
- Dynamic Routing: Easy creation of dynamic routes without complex configuration.
- API Routes: Built-in API routes simplify backend logic integration.
Starting from the Tailwind Next.js Starter Blog
I believe in reusing existing solutions to save time and effort, and a quick search led me to a great starting point: Timothy Lin's Tailwind Next.js Starter Blog. It is a Next.js 13+In version 13, Next.js introduced a new App Router built on React Server Components, which supports shared layouts, nested routing, loading states, error handling, and more. The App Router works in a new directory named app. The app directory works alongside the pages directory to allow for incremental adoption. This allows you to opt some routes of your application into the new behavior while keeping other routes in the pages directory for previous behavior. and Tailwind CSS blogging template that comes preconfigured for technical writing, and it was a solid foundation to build on. I should be clear that what runs this blog today has been heavily reworked since: the routing, layouts, content pipeline, and the entire component layer have all been rewritten or replaced. But the template is what got me moving, and several of its ideas survive in the current codebase.
The main features I relied on and extended are:
- Easy styling customization and mobile-friendly views with Tailwind CSS
- MDX - write JSX in Markdown documents!
- Contentlayer for managing content
- Server-side syntax highlighting with line numbers and line highlighting
- Math display supported via KaTeX
- Citation and bibliography support via rehype-citation
- Automatic image optimization via next/image
- Preconfigured security headers
- SEO friendly with RSS feed and sitemaps
A note on the content pipeline: Contentlayer itself has since become unmaintained, so the blog now runs on contentlayer2, a maintained fork, together with next-contentlayer2.
How the Blog Is Structured
Let's walk through the different folders that make up the blog as it exists today. The site uses the Next.js App Router exclusively; there is no pages directory.
public/static: Next.js serves static files, like images, from thepublicfolder in the root directory. Files insidepubliccan be referenced by your code starting from the base URL/. Thestaticfolder contains images used in the blog, logos, icons, and favicons.scripts: This folder contains scripts that help with the development and maintenance of the blog.- The
build.mjsscript runs after the Contentlayer build and beforenext build(ornext dev): it writes the tag, category, and publication data as JSON files underapp/contentthat the pages read, then callsrss.mjsto generate the RSS feed. - The
rss.mjsscript holds the RSS feed generation; it is imported and run bybuild.mjsrather than invoked on its own.
- The
data: Thedatafolder contains the site metadata, authors, and blog posts in.mdx.authors: contains a single author profile inmdx. Contentlayer still parses it as anAuthordocument type, but nothing in the app reads it today; it is a leftover from the starter template, and theaboutpage is a hand-written React page.blog: contains the blog posts inmdxformat. The folders are used to categorize the blog posts.meta: contains the site metadata like navigation links, site title, description, JSON-LD entities, and the metadata generators.
layouts: Thelayoutsfolder contains the templates used to render content: the post layout that wraps blog articles, plus list, archive, and featured layouts for the index pages.lib: Thelibfolder contains utility functions and configurations used throughout the blog.contentLayer: contains the Contentlayer "plugins" for the content pipeline.computedFields.js: contains the computed fields for the blog posts. Those are fields that are generated based on the content of the blog post or its metadata, like thereadingTimeortoc(table of contents).contentFields.js: defines the fields that appear in the frontmatter for the different types of content (author, post, project, etc.).generateTaxonomies.mjs: loops through all the posts and generates the categories and tags for the blog.projectFields.js: contains the project fields for the projects page.
mdx: contains the remark plugins for the MDX pipeline, likeremark-code-title.js, which rewrites the legacylang:filenamefence syntax into thedata-titleattribute that the design system'sremarkCodeplugin and code fence component render as the block's title.
app: theappfolder contains the application routes and application shell: the blog pages, the API routes (like the dynamic OG image route), the sitemap, and the robots configuration.
One structural decision worth calling out: the reusable React components, MDX components, design tokens, and Tailwind preset do not live in this repository at all. They were extracted into a separate design-system package (@gaudi/design-system), which the blog installs as a local workspace link (link:../design-system) rather than a published version. The root tailwind.config.js consumes the design system's Tailwind preset and scans both the app and the installed package for classes. This keeps the blog repository focused on content and routing, and lets the visual layer evolve independently.
Was It Worth It?
Absolutely. The migration took real effort, especially untangling years of Jekyll-specific templates and plugins, but the result is a codebase I actually enjoy working in. Content is type-checked at build time through the Contentlayer schema, components are reusable and tested in isolation through Storybook, and features that would have required fighting Liquid templates (dynamic OG images, structured data, interactive MDX components) are now just React code. If you are still on Jekyll and feeling the friction, a starter template plus incremental rework is a very manageable path across.
The opinions and views expressed on this blog are solely my own and do not reflect the opinions, views, or positions of my employer or any affiliated organizations. All content provided on this blog is for informational purposes only.
I also offer consulting and mentoring in AI strategy, engineering leadership, and scaling teams. Get in touch if I can help.