Building for the Modern Web
Next.js 15 introduces features that streamline data loading, component rendering, and error handling. For developers building application portfolios and dashboards, understanding how React Server Components (RSC) interact with Client Components is critical. Hydration errors remain a common challenge, often caused by differences between the HTML rendered on the server and the HTML initialized in the browser.
This article walks through the process of setting up a dynamic project feed in Next.js 15, connecting it to a Postgres database via Drizzle ORM, and implementing client-side hydration boundaries.
The Architecture of Hydration
Hydration is the process where React links event handlers to the static HTML sent from the server. If the server-rendered HTML doesn't match the client-rendered HTML, React throws a hydration warning. In Next.js 15, hydration diagnostics are more descriptive, helping identify the exact HTML tags causing the mismatch.
Common causes include:
- Incorrect nesting: Putting block elements (like
div) inside inline elements (likep). - Browser-only APIs: Using variables like
windoworlocalStorageduring initial server render. - Date/Time formatting: Rendering timezone-dependent dates on the server that display differently on the client.
To handle dynamic dates safely, you can use a custom hydration wrapper or initialize rendering only after the component mounts in the browser.
Code Example: Connecting Next.js 15 to Drizzle and Neon Postgres
Here is a TypeScript server component that queries project documents from a Postgres database using Drizzle ORM. It includes dynamic caching controls and handles loading states:
import { db } from '@/lib/db';
import { projectsTable } from '@/lib/schema';
import { desc } from 'drizzle-orm';
import { Suspense } from 'react';
// Next.js 15 dynamic fetch config
export const revalidate = 3600; // Cache for 1 hour
interface Project {
id: number;
title: string;
description: string;
url: string;
createdAt: Date;
}
// Server Component
async function ProjectList() {
// Direct database query executed on the server
const projects: Project[] = await db
.select()
.from(projectsTable)
.orderBy(desc(projectsTable.createdAt))
.limit(10);
if (projects.length === 0) {
return <p className="text-gray-400">No projects found.</p>;
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{projects.map((project) => (
<div key={project.id} className="p-6 rounded-lg bg-white/5 border border-white/10 hover:border-accent transition-colors">
<h3 className="text-xl font-bold mb-2">{project.title}</h3>
<p className="text-gray-400 mb-4">{project.description}</p>
<a
href={project.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-accent hover:underline"
>
View Project
</a>
</div>
))}
</div>
);
}
// Parent Page Layout
export default function ProjectsPage() {
return (
<section className="max-w-4xl mx-auto px-6 py-12">
<h2 className="text-3xl font-bold mb-8">Selected Projects</h2>
<Suspense fallback={<p className="text-gray-400">Loading projects...</p>}>
<ProjectList />
</Suspense>
</section>
);
}Hydration Safe Wrapper Component
When you must display client-only data (such as local time or local storage status), wrap the component in a hydration safety check:
'use client';
import { useState, useEffect } from 'react';
export function ClientOnlyWrapper({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
// Return placeholder during server-side render
return <div className="animate-pulse bg-white/5 h-20 w-full rounded-md" />;
}
return <>{children}</>;
}Using these server-side database connections and client-side hydration wrappers, you can build responsive, fast-loading portfolios that maintain data consistency across server and client states.
