EmpireUI
Get Pro
← Blog8 min read#tailwind#blog#layout

Blog Layout in Tailwind: Article Page, Card Grid, Sidebar

Build a full blog layout in Tailwind — article page with typography, responsive card grid, and a sticky sidebar — with copy-paste Next.js code.

code editor screen showing blog layout development work

Why Blog Layout Is Harder Than It Looks

A blog sounds simple. A list of cards, an article page, maybe a sidebar. Ship it in a morning, right? Wrong. The details that separate a blog people trust from one they bounce off in three seconds — readable line lengths, real typographic hierarchy, a sidebar that doesn't collapse into chaos on tablet — are exactly the kind of things that eat your afternoon.

Tailwind gives you the building blocks, but it doesn't make design decisions for you. You still need to know that a comfortable reading column is somewhere between 65 and 75 characters wide (roughly max-w-prose or max-w-2xl), that your line-height on body text should be at least 1.7, and that a card grid needs a different gap at 768px than at 1280px. These are not Tailwind problems — they're design decisions that Tailwind exposes more clearly than raw CSS.

In practice, most developers skip the typography work and ship text-base everywhere, then wonder why the blog feels flat. Spend 20 minutes on type scale and you'll see the difference immediately. The rest of this article walks through all three pieces: the card grid index, the article reading view, and the optional sidebar — with working Next.js 14 code you can drop straight in.

One more thing — if you want a head start on visual polish beyond the layout itself, Empire UI ships pre-styled blog card components across multiple aesthetics. Worth a look before you hand-craft everything from scratch.

Card Grid: The Blog Index Page

The index page is a grid of cards. Simple. But the grid breakpoints matter a lot. You want one column on mobile (< 640px), two at sm, and three at lg. You probably don't want four — long post titles wrap badly in a four-column grid on 1280px screens and readers have to scan too wide.

// app/blog/page.tsx
import Link from 'next/link';
import { getAllPosts } from '@/lib/posts';

export default async function BlogIndex() {
  const posts = await getAllPosts();

  return (
    <main className="max-w-7xl mx-auto px-4 sm:px-6 py-16">
      <h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-white mb-12">
        Blog
      </h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
        {posts.map((post) => (
          <BlogCard key={post.slug} post={post} />
        ))}
      </div>
    </main>
  );
}

The BlogCard component is where most of the design work lives. You need a cover image, a category badge, title, excerpt, read time, date, and author — but if you show all of that every time, it's noisy. Honestly, the excerpt is the first thing to cut when cards feel crowded. Title + category + date + read time is often enough.

// components/BlogCard.tsx
import Link from 'next/link';
import Image from 'next/image';

interface Post {
  slug: string;
  title: string;
  date: string;
  readMin: number;
  excerpt: string;
  image: string;
  imageAlt: string;
  category: string;
}

export function BlogCard({ post }: { post: Post }) {
  return (
    <Link
      href={`/blog/${post.slug}`}
      className="group flex flex-col rounded-2xl overflow-hidden border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 hover:shadow-xl transition-shadow duration-300"
    >
      <div className="relative aspect-video overflow-hidden">
        <Image
          src={post.image}
          alt={post.imageAlt}
          fill
          className="object-cover group-hover:scale-105 transition-transform duration-500"
        />
      </div>
      <div className="flex flex-col gap-3 p-6">
        <span className="text-xs font-semibold uppercase tracking-widest text-violet-600 dark:text-violet-400">
          {post.category}
        </span>
        <h2 className="text-lg font-bold leading-snug text-gray-900 dark:text-white line-clamp-2">
          {post.title}
        </h2>
        <p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
          {post.excerpt}
        </p>
        <div className="mt-auto flex items-center gap-2 text-xs text-gray-400">
          <time dateTime={post.date}>{post.date}</time>
          <span>·</span>
          <span>{post.readMin} min read</span>
        </div>
      </div>
    </Link>
  );
}

Worth noting: that group-hover:scale-105 on the image is a 2023-era trick that still holds up in 2026. It's subtle enough that it doesn't feel cheap, but it gives the card a sense of interactivity that a plain border-color change doesn't. The line-clamp-2 on title and excerpt keeps the grid visually consistent even when title lengths vary wildly — which they always do.

Article Page: Typography That Actually Reads

The article page lives or dies by typography. You can have a perfect card grid and lose readers the moment they click through to a wall of text-base text-gray-700 with no breathing room. This is where @tailwindcss/typography (the prose plugin) earns its keep.

Install it if you haven't: npm install @tailwindcss/typography, add require('@tailwindcss/typography') to your tailwind.config.js plugins array. Then the prose classes handle heading scale, paragraph spacing, code block styling, blockquotes — the whole reading experience — in one className="prose dark:prose-invert" on your content wrapper.

// app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/lib/posts';
import { notFound } from 'next/navigation';

export default async function BlogPost({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPostBySlug(params.slug);
  if (!post) notFound();

  return (
    <main className="max-w-7xl mx-auto px-4 sm:px-6 py-16">
      <div className="flex flex-col lg:flex-row gap-12">
        {/* Article column */}
        <article className="min-w-0 flex-1">
          <header className="mb-10">
            <span className="text-sm font-semibold text-violet-600 uppercase tracking-widest">
              {post.category}
            </span>
            <h1 className="mt-2 text-3xl sm:text-4xl font-bold tracking-tight text-gray-900 dark:text-white leading-tight">
              {post.title}
            </h1>
            <div className="mt-4 flex items-center gap-3 text-sm text-gray-500">
              <time dateTime={post.date}>{post.date}</time>
              <span>·</span>
              <span>{post.readMin} min read</span>
            </div>
          </header>

          <div
            className="prose prose-lg prose-gray dark:prose-invert max-w-prose"
            dangerouslySetInnerHTML={{ __html: post.contentHtml }}
          />
        </article>

        {/* Sidebar — covered next section */}
        <Sidebar post={post} />
      </div>
    </main>
  );
}

The max-w-prose constraint on the content wrapper is non-negotiable. It translates to 65ch — roughly 65 characters per line — which is the typographic sweet spot for sustained reading. Don't skip it because you want the article to 'fill the page.' A full-width article body at 1280px is an accessibility problem, not a design win.

Quick aside: prose-lg bumps the base font size to 18px (1.125rem) which is the right call for blog reading on desktop. prose defaults to 16px — fine, but tighter. If your brand skews editorial or long-form, go prose-lg. If it's more of a quick-tips format, prose is plenty.

Sticky Sidebar: Related Posts and Table of Contents

The sidebar is where a lot of layouts go wrong. Developers make it position: fixed, it overlaps content on tablet, everything breaks at 1024px, and they give up and remove it. The right approach is sticky with a top offset, constrained to the article column's height via the flex parent.

// components/Sidebar.tsx
import Link from 'next/link';

interface SidebarProps {
  post: { tags: string[]; related: { slug: string; title: string }[] };
}

export function Sidebar({ post }: SidebarProps) {
  return (
    <aside className="hidden lg:block w-72 flex-shrink-0">
      <div className="sticky top-24 flex flex-col gap-8">
        {/* Tags */}
        <div>
          <h3 className="text-xs font-semibold uppercase tracking-widest text-gray-400 mb-3">
            Tags
          </h3>
          <div className="flex flex-wrap gap-2">
            {post.tags.map((tag) => (
              <span
                key={tag}
                className="px-3 py-1 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
              >
                {tag}
              </span>
            ))}
          </div>
        </div>

        {/* Related posts */}
        <div>
          <h3 className="text-xs font-semibold uppercase tracking-widest text-gray-400 mb-3">
            Related
          </h3>
          <ul className="flex flex-col gap-3">
            {post.related.map((rel) => (
              <li key={rel.slug}>
                <Link
                  href={`/blog/${rel.slug}`}
                  className="text-sm text-gray-700 dark:text-gray-300 hover:text-violet-600 dark:hover:text-violet-400 transition-colors leading-snug"
                >
                  {rel.title}
                </Link>
              </li>
            ))}
          </ul>
        </div>
      </div>
    </aside>
  );
}

That hidden lg:block on the aside means mobile gets a clean single-column experience with no sidebar at all. On tablets (768px–1023px) same deal — you don't want a cramped 200px sidebar eating into your already-narrow reading column. The sticky top-24 keeps the sidebar in view as the reader scrolls, anchored 96px from the top to clear your navigation bar.

Look, top-24 is 96px in Tailwind's default scale. If your nav is shorter (say, 60px), you'd drop to top-16. Match it to your actual header height — this is one of those details that's obvious when it's wrong (sidebar content hidden under the nav) but invisible when it's right.

One more thing — if you want a table of contents instead of or alongside related posts, you'll need to extract headings from your post content and build a toc array server-side. The same sidebar structure works; just swap in <a href={#${heading.id}}> links and add scroll-smooth to your <html> element.

Dark Mode: The One Thing Everyone Forgets Until Too Late

Your blog needs dark mode. Not because it's trendy — because a significant portion of your readers will be on it, especially after 9pm. Tailwind's dark: variant makes this manageable, but you have to be disciplined about it from the start. Retrofitting dark mode into a finished layout is a nightmare.

Set darkMode: 'class' in tailwind.config.js and toggle a dark class on <html> via a theme toggle component. Every background color needs a dark: counterpart: bg-white dark:bg-gray-950, text-gray-900 dark:text-gray-100, border-gray-200 dark:border-gray-800. The prose-invert class on your article body handles the bulk of the typography dark mode automatically — that's one of @tailwindcss/typography's best features.

// components/ThemeToggle.tsx
'use client';
import { useEffect, useState } from 'react';

export function ThemeToggle() {
  const [dark, setDark] = useState(false);

  useEffect(() => {
    document.documentElement.classList.toggle('dark', dark);
  }, [dark]);

  return (
    <button
      onClick={() => setDark(!dark)}
      className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
      aria-label="Toggle dark mode"
    >
      {dark ? '☀️' : '🌙'}
    </button>
  );
}

That's a barebones version — in production you'd persist the preference to localStorage and read it on first render to avoid flash of wrong theme. The theme-toggle-react article on this blog covers that pattern in full detail. Worth reading before you ship.

SEO Metadata and Open Graph in Next.js 14

You've got a beautiful layout. Does Google see it? Does a Twitter card render when someone shares it? This is where blog projects often ship half-finished. Next.js 14's generateMetadata function in the App Router makes this straightforward — but you have to actually write it.

// app/blog/[slug]/page.tsx  (metadata export)
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await getPostBySlug(params.slug);
  if (!post) return {};

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.image, alt: post.imageAlt, width: 1200, height: 630 }],
      type: 'article',
      publishedTime: post.date,
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.image],
    },
  };
}

The OG image dimensions matter — 1200x630px is the canonical size that renders well on Twitter, LinkedIn, Facebook, and Discord link previews. If your cover images are sized differently, you'll get cropping artifacts. Worth noting: the publishedTime field in openGraph is read by Google's Article structured data parser and can improve how your posts appear in search results.

For the blog index page, add a generateStaticParams export so Next.js pre-renders all post pages at build time rather than on demand. This is free performance — static HTML served from CDN edge, sub-50ms TTFB — and it's trivially easy in the App Router: export async function generateStaticParams() { return posts.map(p => ({ slug: p.slug })); }.

That said, if your blog updates frequently (more than once a day), consider revalidate = 3600 (1 hour ISR) instead of fully static. The page-transitions-nextjs article touches on how routing strategy affects perceived performance between these two approaches.

Finishing Touches: Responsive Typography and Reading Progress

Two small details that separate a blog that feels polished from one that feels like a template. First: responsive heading sizes. text-3xl sm:text-4xl lg:text-5xl on your H1 sounds obvious but gets skipped constantly. A 48px heading on a 375px screen is not a good time. Always test your article header on an iPhone SE viewport (320px) before shipping.

Second: a reading progress bar. Dead simple to implement, genuinely useful for long articles, and readers notice when it's there. It's a fixed bar at the top of the page, h-1, colored with your brand accent, width driven by scrollY / documentHeight * 100.

// components/ReadingProgress.tsx
'use client';
import { useEffect, useState } from 'react';

export function ReadingProgress() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    const update = () => {
      const { scrollTop, scrollHeight, clientHeight } =
        document.documentElement;
      const pct = (scrollTop / (scrollHeight - clientHeight)) * 100;
      setProgress(Math.min(pct, 100));
    };
    window.addEventListener('scroll', update, { passive: true });
    return () => window.removeEventListener('scroll', update);
  }, []);

  return (
    <div
      className="fixed top-0 left-0 z-50 h-1 bg-violet-500 transition-all duration-100"
      style={{ width: `${progress}%` }}
    />
  );
}

Mount that in your article layout (not the blog index) and you're done. The passive: true on the scroll listener is important — it tells the browser you won't call preventDefault(), which lets it optimize scroll handling on mobile. Without it, Chrome on Android adds a slight scroll delay.

At this point you have a card grid index, a typographically solid article page, a sticky sidebar, dark mode, SEO metadata, and a reading progress indicator. That's a complete, production-ready blog built entirely in Tailwind. If you want to push the visual design further — glassmorphism cards, aurora backgrounds, custom cursors — browse components and grab what fits. The layout bones you just built are solid enough to carry any visual style on top.

FAQ

Do I need @tailwindcss/typography for a blog, or can I style it manually?

You don't need it, but it saves real time. The plugin handles ~30 typography decisions (heading scale, paragraph spacing, code block styling, blockquote borders) in one prose class. Rolling it manually means you'll hit at least half those same decisions yourself — usually worse.

How do I stop my sidebar from overlapping content on tablet?

Use hidden lg:block on the sidebar element so it only appears at 1024px and wider. At tablet widths, drop the sidebar entirely and put related posts below the article body instead.

What's the right max-width for a blog article body?

Tailwind's max-w-prose class sets 65ch — approximately 65 characters per line — which is the typographic standard for comfortable sustained reading. Don't go wider than that for body text, regardless of how much screen space is available.

Should I use SSG or ISR for a Next.js blog?

Static generation (generateStaticParams) is best for blogs that update infrequently — you get CDN-cached HTML with near-instant load times. If you're publishing multiple times a day, use ISR with revalidate = 3600 so new posts appear within an hour without a full rebuild.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

@tailwindcss/typography Plugin: Beautiful Prose Styles for BlogsTailwind Dashboard Layout: Sidebar, Header and Content GridGlassmorphism Blog Layout: Frosted Article Cards and Reading ViewCSS Subgrid: Real Layout Problems It Solves That Grid Can't