Portfolio Blog Starter With Next Themes Toggle.
If you're reading this, you’ve probably noticed that I’m using the Vercel template myself. I customized it a bit, adding a light and dark mode toggle for convenience, and now I can share it with you.
To clarify, I only added a few components and changed the CSS structure; the rest is exactly the same as the original Vercel portfolio template. This template already includes:
Features:
- MDX and Markdown support
- Optimized for SEO (sitemap, robots, JSON-LD schema)
- RSS Feed
- Dynamic OG images
- Syntax highlighting
- Vercel Speed Insights / Web Analytics
- Geist font
- Next-Themes Light & Dark Mode Toggle. (New, added by me.)
Live Demo:
https://portfolio-starter-with-toggle.vercel.app/
Clone, Deploy & GitHub Repository:
Execute create-next-app
with pnpm to bootstrap the example:
pnpm create next-app --example https://github.com/JPerez00/portfolio-starter-toggle/tree/main your-project-name-here
Then, run Next.js in development mode:
pnpm dev
Or just go to the GitHub Repo and star the project 😁
https://github.com/JPerez00/portfolio-starter-with-toggle
Step-by-step Changes Made:
Okay so, I'm not an expert, these are just the steps that worked for me, if you want to follow along, this is exactly what I did:
1. Reinstall Old Dependencies & Install New Packages:
I was having issues with the alpha version of Tailwind 4, so I reverted back to the latest stable release and I also installed Next-Themes.
pnpm add tailwindcss@latest postcss@latest autoprefixer@latest next-themes
2. Create a 'tailwind.config.js' file at the root of your project and add the following configuration:
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class', // Enable class-based dark mode
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
};
3. Update your 'postcss.config.js' to ensure it includes Tailwind CSS:
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
4. Update your 'global.css' to include the necessary Tailwind directives, this includes imports and the pre & code (bash) colors:
@tailwind base;
@tailwind components;
@tailwind utilities;
::selection {
background-color: #47a3f3;
color: #fefefe;
}
/* Light mode variables */
:root {
--sh-class: #2d5e9d;
--sh-identifier: #354150;
--sh-sign: #8996a3;
--sh-string: #007f7a;
--sh-keyword: #e02518;
--sh-comment: #a19595;
--sh-jsxliterals: #6266d1;
--sh-property: #e25a1c;
--sh-entity: #e25a1c;
}
/* Dark mode variables */
.dark {
--sh-class: #4c97f8;
--sh-identifier: white;
--sh-keyword: #f47067;
--sh-string: #0fa295;
}
html {
min-width: 360px;
}
.prose .anchor {
@apply absolute invisible no-underline;
margin-left: -1em;
padding-right: 0.5em;
width: 80%;
max-width: 700px;
cursor: pointer;
}
.anchor:hover {
@apply visible;
}
.prose a {
@apply underline transition-all decoration-neutral-400 dark:decoration-neutral-600 underline-offset-2 decoration-[0.1em];
}
.prose .anchor:after {
@apply text-neutral-300 dark:text-neutral-700;
content: '#';
}
.prose *:hover > .anchor {
@apply visible;
}
.prose pre {
@apply bg-neutral-50 dark:bg-neutral-900 rounded-lg overflow-x-auto border border-neutral-200 dark:border-neutral-900 py-2 px-3 text-sm;
}
.prose code {
@apply px-1 py-0.5 rounded-lg;
}
.prose pre code {
@apply p-0;
border: initial;
line-height: 1.5;
}
.prose code span {
@apply font-medium;
}
.prose img {
/* Don't apply styles to next/image */
@apply m-0;
}
.prose p {
@apply my-4 text-neutral-800 dark:text-neutral-200;
}
.prose h1 {
@apply text-4xl font-medium tracking-tight mt-6 mb-2;
}
.prose h2 {
@apply text-xl font-medium tracking-tight mt-6 mb-2;
}
.prose h3 {
@apply text-xl font-medium tracking-tight mt-6 mb-2;
}
.prose h4 {
@apply text-lg font-medium tracking-tight mt-6 mb-2;
}
.prose strong {
@apply font-medium;
}
.prose ul {
@apply list-disc pl-6;
}
.prose ol {
@apply list-decimal pl-6;
}
.prose > :first-child {
/* Override removing top margin, causing layout shift */
margin-top: 1.25em !important;
margin-bottom: 1.25em !important;
}
pre::-webkit-scrollbar {
display: none;
}
pre {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Remove Safari input shadow on mobile */
input[type='text'],
input[type='email'] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
table {
display: block;
max-width: fit-content;
overflow-x: auto;
white-space: nowrap;
}
.title {
text-wrap: balance;
}
5. Create the 'mode-toggle.tsx' component in the "app/components/" directory:
"use client";
import { useTheme } from "next-themes";
export function ModeToggle() {
const { setTheme, theme } = useTheme();
const handleToggle = () => {
console.log('Current theme:', theme);
setTheme(theme === "light" ? "dark" : "light");
};
return (
<button
onClick={handleToggle}
className="border rounded-md w-6 h-6 flex items-center justify-center border-current"
>
<span className="sr-only">Toggle mode</span>
{theme !== "dark" ? (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
)}
</button>
);
}
6. Create the 'theme-provider.tsx' component in the "app/components/" directory. This component will wrap your whole application:
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
export default function ThemeProvider({ children }: { children: React.ReactNode }) {
return <NextThemesProvider attribute="class">{children}</NextThemesProvider>;
}
7. Update your "app/layout.tsx" file to use the 'ThemeProvider' component and add the 'suppressHydrationWarning':
import './global.css';
import type { Metadata } from 'next';
import { GeistSans } from 'geist/font/sans';
import { GeistMono } from 'geist/font/mono';
import { Navbar } from './components/nav';
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
import Footer from './components/footer';
import { baseUrl } from './sitemap';
import ThemeProvider from './components/theme-provider';
export const metadata: Metadata = {
metadataBase: new URL(baseUrl),
title: {
default: 'Next.js Portfolio Starter',
template: '%s | Next.js Portfolio Starter',
},
description: 'This is my portfolio.',
openGraph: {
title: 'My Portfolio',
description: 'This is my portfolio.',
url: baseUrl,
siteName: 'My Portfolio',
locale: 'en_US',
type: 'website',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
const cx = (...classes) => classes.filter(Boolean).join(' ');
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={cx(GeistSans.variable, GeistMono.variable)}
suppressHydrationWarning
>
<body className="text-black bg-white dark:text-white dark:bg-black antialiased max-w-xl mx-4 mt-8 lg:mx-auto">
<ThemeProvider>
<main className="flex-auto min-w-0 mt-6 flex flex-col px-2 md:px-0">
<Navbar />
{children}
<Footer />
</main>
<Analytics />
<SpeedInsights />
</ThemeProvider>
</body>
</html>
);
}
8. Update your 'nav.tsx' file, inside your "app/components/" directory, to include the 'mode-toggle.tsx' component.
'use client';
import Link from 'next/link';
import { ModeToggle } from './mode-toggle';
const navItems = {
'/': {
name: 'home',
},
'/blog': {
name: 'blog',
},
'https://github.com/JPerez00/portfolio-starter-with-toggle': {
name: 'deploy',
},
}
export function Navbar() {
return (
<aside className="-ml-[8px] mb-16 tracking-tight">
<div className="lg:sticky lg:top-20">
<nav
className="flex flex-row items-start relative px-0 pb-0 fade md:overflow-auto scroll-pr-6 md:relative"
id="nav"
>
<div className="flex flex-1">
<div className="transition-all hover:text-neutral-800 dark:hover:text-neutral-200 flex align-middle relative py-1 m-1">
<ModeToggle />
</div>
</div>
<div className="flex flex-row space-x-0">
{Object.entries(navItems).map(([path, { name }]) => {
return (
<Link
key={path}
href={path}
className="transition-all hover:text-neutral-800 dark:hover:text-neutral-200 flex align-middle relative py-1 pl-2 m-1"
>
{name}
</Link>
);
})}
</div>
</nav>
</div>
</aside>
);
}
Debugging & Conclusion:
Basically, what we did here is ensuring the project can manage the theme context and persist the theme in local storage:
- We added the Tailwind CSS Dark Mode Configuration.
- We created a Theme Context for managing the theme state.
- We modified the Nav component to include the toggle button.
- Saved the theme state in local storage.
- Debugging/Console log added.
Open the browser console to verify if the console.log('Current theme:', theme)
statement prints when you click the button. This confirms the toggle function is being called.
And that's it! that's all I did to make the Next-themes and the toggle button work. Hope that helps! Please star on GitHub if you found this helpful! cheers!
https://github.com/JPerez00/portfolio-starter-with-toggle