7 Silent Bugs I Found Redesigning My Next.js Portfolio (and How I Fixed Them)
Real bugs from redesigning a Next.js 16 + Payload CMS site: a sticky header broken by overflow-x, a font that never loaded, a hydration mismatch, a 404 from Gemini, and more — with fixes.
.png&w=3840&q=75)
None of these bugs threw an error. Each one shipped, passed the build, and quietly made the site worse. Here's what they were, why they happened, and the one-line-ish fix for each.
I recently redesigned this site — new hero, new colour system, new header and footer — on Next.js 16, Tailwind CSS and Payload CMS. The visual work was the easy part. The interesting part was what I found underneath: seven bugs that had been live for months, none of which produced an error message.
That's the common thread. A crash gets fixed the same day. A bug that silently degrades the experience can survive for a year. If you run a Next.js site, at least one of these is probably on yours.
1. The sticky header that wasn't sticky
The header had position: sticky; top: 0. In the browser, it scrolled away with the page like any other element.
The cause was two lines in the global CSS, added long ago to stop horizontal scrolling on mobile:
1html,
2body {
3 width: 100%;
4 overflow-x: hidden;
5}When both html and body have a non-visible overflow, the html value is applied to the viewport — but body becomes a scroll container of its own. A sticky element sticks relative to its nearest scroll container. The header was now sticking inside <body>, which never scrolls (the window does), so it never stuck.
The fix is overflow-x: clip. It still prevents sideways scrolling, but clip doesn't create a scroll container:
1html,
2body {
3 width: 100%;
4 overflow-x: clip;
5}How to check yours: scroll down and run getComputedStyle(document.body).overflowY in the console. If it isn't visible, sticky descendants may be affected.
2. The font that never loaded
The layout loaded Nunito Sans with next/font, exactly as the docs show — but every heading on the live site rendered in the system font. Nothing in the console.
1const nunito = Nunito_Sans({ subsets: ['latin'], variable: '--font-nunito-sans' })
2
3// The variable class was on <body>…
4<html lang="en">
5 <body className={nunito.variable}>Two things were wrong. First, nothing in the Tailwind config used --font-nunito-sans, so the font downloaded and was never applied. Second — and this is the trap — the obvious fix makes things worse. Tailwind's base styles put font-family on html. If you point fontFamily.sans at var(--font-nunito-sans) while the variable is defined on body, the variable is undefined on html, the whole declaration becomes invalid, and the page falls back to the browser default: Times New Roman.
The fix has two parts — define the variable on <html>, and give var() a fallback so any layout that doesn't load the font still gets the system stack:
1<html lang="en" className={nunito.variable}>1// tailwind.config.mjs
2fontFamily: {
3 sans: ['var(--font-nunito-sans, ui-sans-serif)', ...defaultTheme.fontFamily.sans],
4},3. A hydration mismatch only dark-mode visitors saw
I added a light/dark toggle to the header. In light mode, fine. In dark mode, React logged: "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties."
The component chose its icon and aria-label from the current theme:
1const { theme } = useTheme()
2const isDark = theme === 'dark'
3return <button aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}>…</button>The server can't know a visitor's theme — it lives in localStorage — so the server rendered the light version, and dark-mode browsers hydrated the dark version. Different attributes, mismatch.
The fix is to make the rendered markup theme-independent and let CSS do the switching. With Tailwind's darkMode: ['selector', '[data-theme="dark"]'], the dark: variant follows the attribute on <html>:
1export function ThemeToggle() {
2 const { setTheme } = useTheme()
3 const toggle = () => {
4 const current = document.documentElement.getAttribute('data-theme')
5 setTheme(current === 'dark' ? 'light' : 'dark')
6 }
7 return (
8 <button type="button" onClick={toggle} aria-label="Toggle light and dark theme">
9 <Sun className="scale-0 dark:scale-100" />
10 <Moon className="absolute scale-100 dark:scale-0" />
11 </button>
12 )
13}The click handler reads the theme at click time, so nothing theme-dependent is rendered on the server.
4. "Not Found" from Gemini through the Vercel AI SDK
My portfolio's AI assistant can run on Claude, Groq or Gemini. Groq and Gemini are reached through their OpenAI-compatible endpoints with @ai-sdk/openai. After an upgrade, every Gemini request failed with AI_APICallError: Not Found.
The cause: recent versions of the AI SDK's OpenAI provider route provider(modelId) to OpenAI's newer Responses API (/responses). The OpenAI-compatible endpoints from Gemini and Groq only implement Chat Completions. So the SDK was calling a URL that doesn't exist there.
1const gemini = createOpenAI({
2 baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai',
3 apiKey: process.env.GEMINI_API_KEY,
4})
5
6// ❌ uses the Responses API → 404 on OpenAI-compatible providers
7const model = gemini('gemini-2.5-flash')
8
9// ✅ explicitly use Chat Completions
10const model = gemini.chat('gemini-2.5-flash')Lesson: "OpenAI-compatible" usually means compatible with Chat Completions, not with everything OpenAI ships.
5. The chat that answered with nothing
Even before that fix, the symptom was strange: the chat endpoint returned HTTP 200 with an empty body. No error in the UI — just an empty reply bubble.
With streamText, the response starts streaming as soon as you return it. If the model call fails afterwards — bad API key, quota, outage — the 200 status has already been sent and can't change. The error was swallowed.
Two small changes make this visible. On the server, log errors that happen mid-stream:
1const result = streamText({
2 model,
3 system,
4 messages,
5 onError: ({ error }) => {
6 console.error('[portfolio-chat] model error:', error)
7 },
8})
9return result.toTextStreamResponse()On the client, treat an empty stream as a failure instead of rendering a blank message:
1if (!assistantText.trim()) {
2 setLastMessage("Sorry, I couldn't generate an answer just now. Please try again.")
3}And while you're there: don't return internal error strings like GEMINI_API_KEY is not configured to the browser. Log them, and send visitors a generic "temporarily unavailable" message.
6. An image sizes attribute the browser ignored
Every image on the site had this sizes value, generated by a helper from the original template:
1sizes="(max-width: 1920px) 3840w, (max-width: 1536px) 3072w, (max-width: 1280px) 2560w"sizes must contain lengths — 100vw, 400px, 50vw — describing how wide the image will be displayed. The w unit belongs in srcset, not here. Browsers treat the whole value as invalid and fall back to 100vw, so a 400px card downloads an image sized for the full viewport.
The fix is to default to a valid value and pass real sizes from the components that know their layout:
1const sizes = sizeFromProps ?? '100vw'
2
3// a card in a 3-column grid:
4<Media resource={cover} size="(min-width: 1024px) 368px, (min-width: 640px) 50vw, 100vw" />7. A scroll-spy that never highlighted anything
The header nav was supposed to highlight the section you're reading. It never did. The hook looked for links starting with #:
1const sectionIds = hrefs.filter((href) => href.startsWith('#')).map((href) => href.slice(1))But the CMS stored the nav links as absolute URLs — https://www.tayloramitverma.com/#about — so the filter matched nothing. A small normaliser fixed both the highlight and the smooth-scroll click handler, which had the same blind spot:
1export function toSectionHash(href: string): string {
2 if (href.startsWith('#')) return href
3 const match = href.match(/^(?:https?:\/\/(?:www\.)?tayloramitverma\.com)?\/?(#[^/?]+)$/i)
4 return match ? match[1] : href
5}Three quick ones
- A Tailwind class that doesn't exist. I wrote
h-4.5 w-4.5for an icon. Tailwind v3's default scale has3.5but not4.5, so no CSS was generated and the icon silently rendered at its 24px default. Useh-[18px]or extend the spacing scale. - A mobile menu under the chat buttons. The drawer had
z-50; the floating chat and WhatsApp buttons hadz-[100], so they sat on top of the open menu. Write your z-index layers down in one place. - A closed drawer you could still tab into. An off-canvas menu hidden with
translate-x-fullis still focusable. Addinertwhen it's closed so keyboard and screen-reader users don't land on invisible links.
How I caught them
None of these showed up in the build, and only one showed up in the console. What found them was looking at the real thing:
- Screenshots at several widths and both themes, compared side by side.
- Measuring instead of eyeballing — checking
getBoundingClientRect().topof the header after scrolling,naturalWidthof images,scrollWidthvsclientWidthfor overflow. - Calling the API directly with
curlinstead of trusting the UI, which is how the empty 200 response showed up. - Reading computed styles, not source styles. The source said
sticky; the behaviour said otherwise.
If you take one thing from this post: when something "should work" but doesn't, measure what the browser actually did. The bug is usually in the gap between what the code says and what the page does.
Related posts
Share this article
Found it helpful? Share it with your network.
Keep reading
.png&w=3840&q=75)
I Built a RAG Chatbot for My Portfolio Without a Vector Database (BM25 + Next.js + Vercel AI SDK)
How I built the AI assistant on my portfolio with BM25 retrieval instead of embeddings: chunking, a 40-line BM25 in TypeScript, streaming with the Vercel AI SDK, rate limiting, and the retrieval bugs I found.

Next.js Image Optimization That Designers Trust: A Complete next/image Guide (Next.js 16)
Fix blurry heroes, layout shift and slow LCP in Next.js 16. A practical guide to next/image config, the sizes prop, preload, placeholders, remote patterns and CMS images.

Payload CMS Rich Text That Editors Control: A Lexical Configuration Guide
Configure Payload CMS 3 Lexical rich text so editors get custom blocks, callouts and code snippets without breaking your design system — with config and React rendering examples.