Next.js 15 and Tailwind CSS 4: Themed Blog App

Next.js 15, released in October 2024, paired with Tailwind CSS 4, released in January 2025, forms a powerful duo for building modern, performant, and visually stunning web applications. In this tutorial, we’ll walk through creating a themed blog app using Next.js 15’s App Router, Server Components, and Tailwind CSS 4’s streamlined configuration and theming capabilities. We’ll implement a light and dark theme toggle, leveraging Tailwind’s new CSS-first configuration and Next.js 15’s async request handling.

Tailwind

Why Next.js 15 and Tailwind CSS 4?

Next.js 15 introduces features like async Server Components, improved caching, and Turbopack for faster builds, making it ideal for dynamic, data-driven applications like blogs. Its async Server Components enable seamless data fetching directly in the server environment, reducing client-side JavaScript and improving performance for SEO-critical applications. Turbopack, the successor to Webpack, significantly speeds up development with near-instantaneous hot module replacement, enhancing developer productivity. Tailwind CSS 4 simplifies configuration with a CSS-first approach, automatic content detection, and enhanced theming with OKLCH colors, ensuring a flexible and performant styling solution. The OKLCH color space provides perceptually uniform colors, making theme transitions visually smooth and accessible. Together, they enable rapid development of responsive, SEO-friendly, and themeable web apps, ideal for blogs that need to balance aesthetics with performance. For example, a blog with frequent content updates benefits from Next.js’s caching strategies, while Tailwind’s utility-first approach ensures consistent styling across dynamic content without writing extensive custom CSS.

This tutorial assumes familiarity with React and basic Next.js concepts. We’ll cover:

  1. Setting up a Next.js 15 project with Tailwind CSS 4
  2. Creating a blog with async Server Components
  3. Implementing a light/dark theme toggle
  4. Styling with Tailwind CSS 4’s utility classes and custom themes
  5. A practical example with sample code

Prerequisites

  • Node.js 18+ (LTS recommended)
  • Basic knowledge of React, Next.js, and Tailwind CSS
  • A code editor (e.g., VS Code with Tailwind CSS IntelliSense)

Step 1: Project Setup

Let’s create a new Next.js 15 project and configure Tailwind CSS 4.

  1. Create a Next.js Project: npx create-next-app@rc my-themed-blog cd my-themed-blog Choose TypeScript, Tailwind CSS, and the App Router when prompted. This sets up Tailwind CSS 4 automatically.
  2. Verify Tailwind CSS 4 Configuration:
    Open app/globals.css and ensure it includes Tailwind directives: @import "tailwindcss"; @tailwind components; @tailwind utilities; Tailwind CSS 4 doesn’t require a tailwind.config.js file by default, as it uses automatic content detection. However, we’ll customize it for theming later.
  3. Start the Development Server: npm run dev --turbo Visit http://localhost:3000 to confirm the setup works.

Step 2: Setting Up the Blog Structure

We’ll create a simple blog with a homepage listing posts and individual post pages, using Next.js 15’s App Router and async Server Components to fetch data from a mock API (JSONPlaceholder).

Folder Structure

my-themed-blog/
├── app/
│   ├── posts/
│   │   ├── [id]/
│   │   │   └── page.tsx
│   │   └── page.tsx
│   ├── components/
│   │   ├── Navbar.tsx
│   │   ├── ThemeToggle.tsx
│   │   └── PostList.tsx
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── public/
├── tailwind.config.ts
└── next.config.ts

Root Layout (app/layout.tsx)

Create a layout to wrap all pages with a Navbar and theme support:

// app/layout.tsx
import { ThemeToggle } from './components/ThemeToggle';
import { Navbar } from './components/Navbar';
import './globals.css';

export const metadata = {
  title: 'Themed Blog App',
  description: 'A blog built with Next.js 15 and Tailwind CSS 4',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className="bg-background text-foreground transition-colors duration-1000">
        <Navbar />
        <main className="container mx-auto max-w-5xl px-4">
          {children}
        </main>
      </body>
    </html>
  );
}

Navbar Component (app/components/Navbar.tsx)

Create a responsive Navbar with a theme toggle:

// app/components/Navbar.tsx
'use client';

import Link from 'next/link';
import { ThemeToggle } from './ThemeToggle';

export function Navbar() {
  return (
    <nav className="bg-primary text-primary-foreground p-4">
      <div className="container mx-auto max-w-5xl flex justify-between items-center">
        <Link href="/" className="text-2xl font-bold">
          My Blog
        </Link>
        <div className="flex items-center space-x-4">
          <Link href="/posts" className="hover:text-secondary-foreground">
            Posts
          </Link>
          <ThemeToggle />
        </div>
      </div>
    </nav>
  );
}

Step 3: Implementing Theme Support with Tailwind CSS 4

Tailwind CSS 4’s CSS-first configuration allows theme customization directly in globals.css. We’ll define light and dark themes using OKLCH colors and enable dark mode with the class strategy. OKLCH colors are particularly powerful because they offer better perceptual uniformity compared to traditional HSL or RGB, ensuring consistent visual contrast across themes. This is crucial for accessibility, as it helps maintain readable text and UI elements for users with visual impairments. We’ll also ensure that theme transitions are smooth by leveraging Tailwind’s transition utilities, enhancing the user experience during light/dark mode switches.

Update app/globals.css

@import "tailwindcss";

@theme {
  --color-background: oklch(0.99 0 0); /* Light: White */
  --color-foreground: oklch(0.2 0.1 0); /* Light: Dark Gray */
  --color-primary: oklch(0.6 0.2 270); /* Light: Purple */
  --color-primary-foreground: oklch(0.95 0 0); /* Light: Near White */
  --color-secondary: oklch(0.7 0.15 200); /* Light: Cyan */
  --color-secondary-foreground: oklch(0.1 0.05 0); /* Light: Darker Gray */
}

.dark {
  --color-background: oklch(0.2 0.1 0); /* Dark: Dark Gray */
  --color-foreground: oklch(0.95 0 0); /* Dark: Near White */
  --color-primary: oklch(0.7 0.15 270); /* Dark: Lighter Purple */
  --color-primary-foreground: oklch(0.1 0.05 0); /* Dark: Darker Gray */
  --color-secondary: oklch(0.8 0.1 200); /* Dark: Brighter Cyan */
  --color-secondary-foreground: oklch(0.95 0 0); /* Dark: Near White */
}

Theme Toggle Component (app/components/ThemeToggle.tsx)

Create a client-side component to toggle between light and dark themes:

// app/components/ThemeToggle.tsx
'use client';

import { useEffect, useState } from 'react';

export function ThemeToggle() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const savedTheme = localStorage.getItem('theme') || 'light';
    setTheme(savedTheme);
    document.documentElement.classList.toggle('dark', savedTheme === 'dark');
  }, []);

  const toggleTheme = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
    document.documentElement.classList.toggle('dark', newTheme === 'dark');
  };

  return (
    <button
      onClick={toggleTheme}
      className="bg-secondary text-secondary-foreground px-4 py-2 rounded-md hover:bg-secondary/80 transition-colors duration-300"
    >
      {theme === 'light' ? 'Dark Mode' : 'Light Mode'}
    </button>
  );
}

This component persists the theme in localStorage and updates the dark class on the HTML element, triggering Tailwind’s dark mode styles.

Step 4: Building the Blog Pages

We’ll create a homepage, a posts list page, and individual post pages using Next.js 15’s async Server Components to fetch data from JSONPlaceholder.

Homepage (app/page.tsx)

// app/page.tsx
import Link from 'next/link';

export default function Home() {
  return (
    <div className="py-8">
      <h1 className="text-4xl font-bold mb-4">Welcome to My Blog</h1>
      <p className="text-lg mb-4">
        Explore our latest posts on technology, coding, and more.
      </p>
      <Link
        href="/posts"
        className="bg-primary text-primary-foreground px-6 py-3 rounded-md hover:bg-primary/90 transition-colors duration-300"
      >
        View All Posts
      </Link>
    </div>
  );
}

Posts List Page (app/posts/page.tsx)

Use an async Server Component with Suspense for streaming:

// app/posts/page.tsx
import { Suspense } from 'react';
import { PostList } from '../components/PostList';

export default async function PostsPage() {
  return (
    <div className="py-8">
      <h1 className="text-3xl font-bold mb-6">Blog Posts</h1>
      <Suspense fallback={<p className="text-foreground">Loading posts...</p>}>
        <PostList />
      </Suspense>
    </div>
  );
}

Post List Component (app/components/PostList.tsx)

Fetch posts asynchronously:

// app/components/PostList.tsx
import Link from 'next/link';

async function fetchPosts() {
  'use cache';
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    cache: 'force-cache',
    next: { revalidate: 3600 },
  });
  if (!res.ok) throw new Error('Failed to fetch posts');
  return res.json();
}

export async function PostList() {
  const posts = await fetchPosts();
  return (
    <ul className="space-y-4">
      {posts.slice(0, 10).map(post => (
        <li key={post.id} className="border-b border-foreground/20 pb-4">
          <Link
            href={`/posts/${post.id}`}
            className="text-xl font-semibold hover:text-primary transition-colors duration-300"
          >
            {post.title}
          </Link>
          <p className="text-foreground/70 mt-1">{post.body.substring(0, 100)}...</p>
        </li>
      ))}
    </ul>
  );
}

Post Detail Page (app/posts/[id]/page.tsx)

Fetch a single post:

// app/posts/[id]/page.tsx
import { Suspense } from 'react';

async function fetchPost(id: string) {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
    cache: 'force-cache',
  });
  if (!res.ok) throw new Error('Failed to fetch post');
  return res.json();
}

async function PostDetail({ id }: { id: string }) {
  const post = await fetchPost(id);
  return (
    <div className="py-8">
      <h1 className="text-3xl font-bold mb-4">{post.title}</h1>
      <p className="text-foreground/80 leading-relaxed">{post.body}</p>
    </div>
  );
}

export default async function PostPage({ params }: { params: { id: string } }) {
  return (
    <Suspense fallback={<p className="text-foreground">Loading post...</p>}>
      <PostDetail id={params.id} />
    </Suspense>
  );
}

Step 5: Customizing the Theme

To extend the theme, update app/globals.css with additional styles or custom utilities:

@import "tailwindcss";

@theme {
  --color-background: oklch(0.99 0 0);
  --color-foreground: oklch(0.2 0.1 0);
  --color-primary: oklch(0.6 0.2 270);
  --color-primary-foreground: oklch(0.95 0 0);
  --color-secondary: oklch(0.7 0.15 200);
  --color-secondary-foreground: oklch(0.1 0.05 0);
  --ease-fluid: cubic-bezier(0.3, 0, 0, 1);
}

.dark {
  --color-background: oklch(0.2 0.1 0);
  --color-foreground: oklch(0.95 0 0);
  --color-primary: oklch(0.7 0.15 270);
  --color-primary-foreground: oklch(0.1 0.05 0);
  --color-secondary: oklch(0.8 0.1 200);
  --color-secondary-foreground: oklch(0.95 0 0);
}

@layer utilities {
  .container {
    @apply max-w-5xl mx-auto px-4 sm:px-6 lg:px-8;
  }
}

These custom variables ensure consistent theming across components, and the container utility simplifies layout styling.

Step 6: Running and Testing

  1. Start the Development Server: npm run dev --turbo Visit http://localhost:3000 to see the homepage, navigate to /posts for the post list, and click a post to view its details.
  2. Test Theme Switching:
    • Click the theme toggle button in the Navbar to switch between light and dark modes.
    • Verify that colors change (e.g., background, text, and button styles) based on the dark class.
  3. Check Responsiveness:
    Tailwind’s responsive utilities (e.g., sm:, md:) ensure the blog is mobile-friendly. Test on different screen sizes to confirm the layout adapts correctly.

Step 7: Deploying to Vercel

Deploy the blog to Vercel for a production-ready setup:

  1. Push the code to a GitHub repository.
  2. Connect the repository to Vercel via the Vercel dashboard.
  3. Deploy with default settings, ensuring next.config.ts includes:// next.config.ts module.exports = { experimental: { dynamicIO: true, }, };
  4. Visit the deployed URL to see your themed blog live.

Other Changes

  • [Breaking] next/image: Removed squoosh in favor of sharp as an optional dependency (PR)
  • [Breaking] next/image: Changed default Content-Disposition to attachment (PR)
  • [Breaking] next/image: Error when src has leading or trailing spaces (PR)
  • [Breaking] Middleware: Apply react-server condition to limit unrecommended React API imports (PR)
  • [Breaking] next/font: Removed support for external @next/font package (PR)
  • [Breaking] next/font: Removed font-family hashing (PR)
  • [Breaking] Caching: force-dynamic will now set a no-store default to the fetch cache (PR)
  • [Breaking] Config: Enable swcMinify (PR), missingSuspenseWithCSRBailout (PR), and outputFileTracing (PR) behavior by default and remove deprecated options
  • [Breaking] Remove auto-instrumentation for Speed Insights (must now use the dedicated @vercel/speed-insights package) (PR)
  • [Breaking] Remove .xml extension for dynamic sitemap routes and align sitemap URLs between development and production (PR)
  • [Breaking] We’ve deprecated exporting export const runtime = "experimental-edge" in the App Router. Users should now switch to export const runtime = "edge". We’ve added a codemod to perform this (PR)
  • [Breaking] Calling revalidateTag and revalidatePath during render will now throw an error (PR)
  • [Breaking] The instrumentation.js and middleware.js files will now use the vendored React packages (PR)
  • [Breaking] The minimum required Node.js version has been updated to 18.18.0 (PR)
  • [Breaking] next/dynamic: the deprecated suspense prop has been removed and when the component is used in the App Router, it won’t insert an empty Suspense boundary anymore (PR)
  • [Breaking] When resolving modules on the Edge Runtime, the worker module condition will not be applied (PR)
  • [Breaking] Disallow using ssr: false option with next/dynamic in Server Components (PR)
  • [Improvement] Metadata: Updated environment variable fallbacks for metadataBase when hosted on Vercel (PR)
  • [Improvement] Fix tree-shaking with mixed namespace and named imports from optimizePackageImports (PR)
  • [Improvement] Parallel Routes: Provide unmatched catch-all routes with all known params (PR)
  • [Improvement] Config bundlePagesExternals is now stable and renamed to bundlePagesRouterDependencies
  • [Improvement] Config serverComponentsExternalPackages is now stable and renamed to serverExternalPackages
  • [Improvement] create-next-app: New projects ignore all .env files by default (PR)
  • [Improvement] The outputFileTracingRootoutputFileTracingIncludes and outputFileTracingExcludes have been upgraded from experimental and are now stable (PR)
  • [Improvement] Avoid merging global CSS files with CSS module files deeper in the tree (PR)
  • [Improvement] The cache handler can be specified via the NEXT_CACHE_HANDLER_PATH environment variable (PR)
  • [Improvement] The Pages Router now supports both React 18 and React 19 (PR)
  • [Improvement] The Error Overlay now displays a button to copy the Node.js Inspector URL if the inspector is enabled (PR)
  • [Improvement] Client prefetches on the App Router now use the priority attribute (PR)
  • [Improvement] Next.js now provides an unstable_rethrow function to rethrow Next.js internal errors in the App Router (PR)
  • [Improvement] unstable_after can now be used in static pages (PR)
  • [Improvement] If a next/dynamic component is used during SSR, the chunk will be prefetched (PR)
  • [Improvement] The esmExternals option is now supported on the App Router (PR)
  • [Improvement] The experimental.allowDevelopmentBuild option can be used to allow NODE_ENV=development with next build for debugging purposes (PR)
  • [Improvement] The Server Action transforms are now disabled in the Pages Router (PR)
  • [Improvement] Build workers will now stop the build from hanging when they exit (PR)
  • [Improvement] When redirecting from a Server Action, revalidations will now apply correctly (PR)
  • [Improvement] Dynamic params are now handled correctly for parallel routes on the Edge Runtime (PR)
  • [Improvement] Static pages will now respect staleTime after initial load (PR)
  • [Improvement] vercel/og updated with a memory leak fix (PR)
  • [Improvement] Patch timings updated to allow usage of packages like msw for APIs mocking (PR)
  • [Improvement] Prerendered pages should use static staleTime (PR)

To learn more, check out the upgrade guide.

Best Practices

  • Use Async Server Components: Leverage Next.js 15’s async Server Components for data fetching to keep code clean and performant.
  • Optimize Caching: Use force-cache and revalidate for API calls to reduce server load.
  • Stream with Suspense: Wrap async components in Suspense for better UX with loading states.
  • Leverage Tailwind’s Theming: Define custom colors in globals.css for consistency and easy maintenance.
  • Test Theme Transitions: Use Tailwind’s transition-colors and duration-* utilities for smooth theme changes.

For more about to learn next.js new features go to new features.

Conclusion

In this tutorial, we built a themed blog app using Next.js 15 and Tailwind CSS 4, complete with async Server Components, a light/dark theme toggle, and responsive styling. Next.js 15’s features like async request handling and Turbopack, combined with Tailwind CSS 4’s CSS-first configuration, make it easier than ever to create performant, visually appealing applications. Try extending this project by adding a CMS like Sanity or Contentful, integrating MDX for richer content, or experimenting with Tailwind’s advanced theming options.

Ready to get started? Clone the code from this tutorial, deploy it to Vercel, and share your themed blog with the world! Have questions or ideas? Drop them in the comments below, and happy coding!

Leave a Comment

Your email address will not be published. Required fields are marked *