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.

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:
- Setting up a Next.js 15 project with Tailwind CSS 4
- Creating a blog with async Server Components
- Implementing a light/dark theme toggle
- Styling with Tailwind CSS 4’s utility classes and custom themes
- 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.
- 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. - Verify Tailwind CSS 4 Configuration:
Openapp/globals.css
and ensure it includes Tailwind directives:@import "tailwindcss"; @tailwind components; @tailwind utilities;
Tailwind CSS 4 doesn’t require atailwind.config.js
file by default, as it uses automatic content detection. However, we’ll customize it for theming later. - Start the Development Server:
npm run dev --turbo
Visithttp://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
- Start the Development Server:
npm run dev --turbo
Visithttp://localhost:3000
to see the homepage, navigate to/posts
for the post list, and click a post to view its details. - 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.
- 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:
- Push the code to a GitHub repository.
- Connect the repository to Vercel via the Vercel dashboard.
- Deploy with default settings, ensuring
next.config.ts
includes:// next.config.ts module.exports = { experimental: { dynamicIO: true, }, };
- Visit the deployed URL to see your themed blog live.
Other Changes
- [Breaking] next/image: Removed
squoosh
in favor ofsharp
as an optional dependency (PR) - [Breaking] next/image: Changed default
Content-Disposition
toattachment
(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 ano-store
default to the fetch cache (PR) - [Breaking] Config: Enable
swcMinify
(PR),missingSuspenseWithCSRBailout
(PR), andoutputFileTracing
(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 toexport const runtime = "edge"
. We’ve added a codemod to perform this (PR) - [Breaking] Calling
revalidateTag
andrevalidatePath
during render will now throw an error (PR) - [Breaking] The
instrumentation.js
andmiddleware.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 deprecatedsuspense
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 withnext/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 tobundlePagesRouterDependencies
- [Improvement] Config
serverComponentsExternalPackages
is now stable and renamed toserverExternalPackages
- [Improvement] create-next-app: New projects ignore all
.env
files by default (PR) - [Improvement] The
outputFileTracingRoot
,outputFileTracingIncludes
andoutputFileTracingExcludes
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 allowNODE_ENV=development
withnext 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
andrevalidate
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
andduration-*
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!