Adding Version‑Skew Protection & a Full‑Page Reload Button to a Next.js Error Screen (and polishing the iOS/Android app shell)
TL;DR: I hardened the Next.js error page against stale client bundles by checking the NEXT_PUBLIC_BUILD_ID header and showing a “Reload” button that forces a hard navigation. At the same time I tweaked the layout components to render a native‑app‑like shell on iOS/Android when a user is already authenticated.
The Problem
After deploying a new MVP of Home Services Marketplace to Vercel, I started seeing sporadic “Hydration failed” errors in the browser console. The stack trace pointed to a mismatch between the client’s JavaScript bundle and the server‑rendered HTML. In practice, users who kept a tab open across a deployment would hit an error page that displayed a generic “Something went wrong” message, but there was no way to recover without manually closing the tab or clearing the cache.
Typical symptom:
Error: Hydration failed because the initial UI does not match what was rendered on the server.
at ./src/app/layout.tsx (line 42)
The root cause was version skew – the client was still trying to run the old bundle while the server had already switched to a new build. The default Next.js error page (src/app/error.tsx) didn’t expose any recovery path, leaving the user stuck.
What I Tried First
My first instinct was to rely on Next.js’s built‑in router.refresh() inside the error component, hoping it would pull the latest assets automatically. I added:
import { useRouter } from 'next/navigation';
export default function ErrorPage() {
const router = useRouter();
return <button onClick={() => router.refresh()}>Retry</button>;
}
Unfortunately, router.refresh() only re‑executes the server‑side data fetches; it does not reload the client bundle. Users still saw the same hydration error because the stale JavaScript remained in memory. I also tried forcing a hard reload with window.location.reload(), but the button never rendered because the error page aborted before reaching the JSX.
At this point I realized I needed a guard that detects a version mismatch before the error component renders, and a UI that can survive the failure long enough to present a recovery option.
The Implementation
1. Export a build identifier from the server
Next.js automatically injects a NEXT_PUBLIC_BUILD_ID environment variable during the build step. I made it explicit in next.config.ts so it’s also sent as a custom header:
// next.config.ts
import type { NextConfig } from 'next';
const securityHeaders = [
// existing security headers…
];
const nextConfig: NextConfig = {
poweredByHeader: false,
// ---- NEW: version‑skew protection ---------------------------------
// If the client sends a stale X-Build-ID, we short‑circuit the request.
async headers() {
return [
{
source: '/:path*',
headers: [
...securityHeaders,
{ key: 'X-Build-ID', value: process.env.NEXT_PUBLIC_BUILD_ID ?? '' },
],
},
];
},
};
export default nextConfig;
Adding the X‑Build‑ID header makes the client’s current version visible to the server on every request.
2. Detect skew in a middleware
A lightweight middleware runs before each route. It reads the incoming X-Build-ID header and compares it to the current build ID. If they differ, we respond with a 302 redirect to the same URL, forcing the browser to fetch a fresh HTML document (and thus the new bundle).
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const clientBuild = req.headers.get('x-build-id');
const serverBuild = process.env.NEXT_PUBLIC_BUILD_ID ?? '';
if (clientBuild && clientBuild !== serverBuild) {
// Force a full page reload by redirecting
const url = req.nextUrl.clone();
url.searchParams.set('forceReload', '1');
return NextResponse.redirect(url);
}
return NextResponse.next();
}
The middleware is automatically picked up by Next.js (file name middleware.ts in the root). Adding the forceReload query param is optional but useful for debugging.
3. Extend the error page with a “Reload” button
Now that the request can survive a version mismatch, I updated src/app/error.tsx to show a button that triggers a hard navigation. I also added a short comment explaining why the button exists.
// src/app/error.tsx
import { Button } from "@/components/ui/primitives";
/**
* Unexpected error page. The "Reload" button forces a full page load,
* which resolves the typical case where the tab was opened before a new
* deployment and the client bundle is outdated.
*/
export default function ErrorPage({ error }: { error: Error }) {
const handleReload = () => {
// `window.location.href` forces a full navigation, bypassing SPA caching.
window.location.href = window.location.href;
};
return (
<div className="flex h-screen flex-col items-center justify-center">
<h1 className="text-2xl font-bold mb-4">Something went wrong</h1>
<p className="mb-6">{error.message}</p>
<Button onClick={handleReload}>Reload</Button>
</div>
);
}
The Button component is a thin wrapper around a <button> with Tailwind styles, already present in the UI library.
4. iOS/Android “app‑shell” UI for logged‑in users
While fixing the error handling, I also wanted the app to feel native on mobile browsers. The requirement was: if a session cookie exists, render the layout without the standard Next.js navigation bar and with a fullscreen splash that mimics a native app.
I introduced a new layout component src/components/layout/app-shell.tsx and wired it into the various route groups:
// src/components/layout/app-shell.tsx
import { useSession } from "@/hooks/useSession";
export function AppShell({ children }: { children: React.ReactNode }) {
const { status } = useSession(); // returns "authenticated" | "loading" | "unauthenticated"
// Render a minimal chrome only for authenticated sessions
if (status === "authenticated") {
return (
<div className="min-h-screen bg-gray-50">
{/* iOS/Android status bar placeholder */}
<div className="h-6 bg-white" />
{children}
</div>
);
}
// Fallback to the regular site header
return <>{children}</>;
}
Then I updated each layout file that wraps a route group (e.g., src/app/(cuenta)/layout.tsx, src/app/(publico)/layout.tsx, etc.) to import and use AppShell:
// src/app/(cuenta)/layout.tsx
import { SiteHeader } from "@/components/layout/site-header";
import { AppShell } from "@/components/layout/app-shell";
export default function AccountLayout({ children }: { children: React.ReactNode }) {
return (
<AppShell>
<SiteHeader />
<main>{children}</main>
</AppShell>
);
}
The same pattern was applied to src/app/(publico)/layout.tsx, src/app/admin/layout.tsx, src/app/cliente/layout.tsx, and src/app/prestador/layout.tsx. This kept the code DRY and ensured a consistent “app‑like” experience across all authenticated sections.
5. Miscellaneous fixes
-
Port lock –
.env.examplenow defaultsAPP_URLtohttp://localhost:3200to avoid accidental port collisions during local dev (fix(dev): puerto local fijo 3200). -
Vercel trust origins –
next.config.tsreceived anallowedHeadersentry forX-Build-IDto prevent CORS issues on Vercel deployments. - Icon assets
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/home-services-marketplace · 2026-10-18
#playadev #buildinpublic
Top comments (0)