You know the feeling: you finally get sign-off to upgrade your Next.js app, thinking it’ll be a weekend project. Maybe you’re excited about faster builds, or you just want to keep up with the latest. But then, out of nowhere, half your Server Components explode with cryptic errors. That’s exactly what happened to us during our jump to Next.js 15. If you’re running Server Components, or thinking about upgrading, I’m here to walk you through what actually broke, why, and how we got things running smoothly again.
The Upgrade That Broke Our Build
Our app isn’t huge, but we rely heavily on React Server Components (RSC). We love how RSC lets us fetch data on the server, cut down bundle sizes, and generally feel like we’re living in the future. Next.js 15 promised better stability and some under-the-hood improvements, so we thought, “What could go wrong?”
Turns out, a lot. The thing is, Server Components are still evolving fast, and some patterns that "just worked" in Next.js 14 suddenly started throwing errors or behaving weirdly after the upgrade.
The First Error: “You cannot use hooks inside a Server Component”
After the upgrade, a bunch of our pages blew up with errors like:
Error: Hooks can only be used inside Client Components.
We’d seen this before, but we were careful: all our useState, useEffect, and useContext calls were inside use client components (or so we thought). What changed?
Here’s the catch: Next.js 15 tightened how it detects Server vs Client Components. In previous versions, if you accidentally imported a Client Component into a Server Component, it might have silently worked (or failed later). Now, it fails fast.
Example: Importing a Client Component Inside a Server Component
Suppose you have a file structure like this:
/components
- UserProfile.server.jsx
- UserSettings.client.jsx
Previously, you might have written:
// UserProfile.server.jsx
import UserSettings from './UserSettings.client';
export default function UserProfile({ userId }) {
// ...fetch user data from DB
return (
<div>
{/* Some server-rendered stuff */}
<UserSettings userId={userId} />
</div>
);
}
This should work, right? You’re importing a Client Component into a Server Component, which is allowed.
But here’s where it went wrong for us: if UserSettings.client.jsx (or anything it imports) tries to use another Server Component, or imports server-only code, you’ll get runtime errors. The error messages in Next.js 15 are much stricter and earlier.
What we learned: Be absolutely sure your Client Components and their imports are “clean” — i.e., they don’t pull in any server-only code.
Fix: Double-Check Component Boundaries
We ended up splitting some utility functions into /lib/server and /lib/client folders just to be clear about what could be imported where.
// /lib/server/getUserData.js
export async function getUserData(userId) {
// Only server-side DB fetches here
}
// /lib/client/formatDisplayName.js
export function formatDisplayName(user) {
// Only pure JS, no Node APIs
}
Then, in our components, we only import from the right side:
// UserProfile.server.jsx
import { getUserData } from '../lib/server/getUserData';
// UserSettings.client.jsx
import { formatDisplayName } from '../lib/client/formatDisplayName';
This sounds obvious, but with a big codebase, it’s easy to let these boundaries blur.
The Second Error: “Dynamic server usage: fetch() is not allowed in Client Components”
Next.js 15 got more aggressive about enforcing where you can call fetch(). We’d gotten used to calling fetch() in Server Components, but somehow, after the upgrade, we started getting errors like:
Error: fetch() is not allowed in Client Components. Move your data fetching to a Server Component.
How did this happen? We traced it back to a Client Component that imported a helper function from a shared util file. That helper happened to call fetch(). In Next.js 14, it worked — but it was never really safe.
Example: Shared Helper with fetch
// /lib/apiHelpers.js
export async function getWeather(city) {
// This is a server-side API call
const res = await fetch(`https://api.weather.com/v1/${city}`);
return res.json();
}
// WeatherWidget.client.jsx
import { getWeather } from '../lib/apiHelpers';
export default function WeatherWidget({ city }) {
// ❌ This will error in Next.js 15
const [weather, setWeather] = useState(null);
useEffect(() => {
getWeather(city).then(setWeather);
}, [city]);
// ...
}
Why the error? Because getWeather is calling fetch(), which Next.js 15 now blocks in Client Components. In Next.js 14, this might have slipped through, but it was always a footgun.
Fix: Move Data Fetching to Server Components
We refactored like this:
// WeatherWidgetWrapper.server.jsx
import WeatherWidget from './WeatherWidget.client';
import { getWeather } from '../lib/apiHelpers';
export default async function WeatherWidgetWrapper({ city }) {
// ✅ Fetch on server
const weather = await getWeather(city);
return <WeatherWidget weather={weather} />;
}
// WeatherWidget.client.jsx
export default function WeatherWidget({ weather }) {
// Now just display, no fetching
return <div>{weather.temp}°C</div>;
}
This pattern — “fetch on the server, hand off to the client” — became our new default. It’s a bit more verbose, but way safer.
The Surprise: Breaking Changes in Route Handlers
One thing we didn’t expect: Next.js 15 changed how Route Handlers (the new /app/api/route.js stuff) interact with Server Components. We had a couple of Server Components that directly imported handler utilities from our API routes. This just flat-out stopped working.
Example: Importing from Route Handlers (What Not to Do)
// /app/api/users/route.js
export async function getUsers() {
// DB fetch logic
}
// /components/UserList.server.jsx
import { getUsers } from '../app/api/users/route';
export default async function UserList() {
const users = await getUsers();
// ...
}
Why is this bad? In Next.js 15, Route Handlers are built as isolated modules with their own context. Importing server logic from a route file can break assumptions, and can even cause circular dependency issues or deployment failures.
Fix: Share Logic, Not Route Files
We moved shared logic into a true shared module:
// /lib/server/userQueries.js
export async function getUsers() {
// DB logic here
}
Then, in both the route handler and the Server Component:
// /app/api/users/route.js
import { getUsers } from '../../../lib/server/userQueries';
export async function GET(request) {
const users = await getUsers();
// ...
}
// /components/UserList.server.jsx
import { getUsers } from '../lib/server/userQueries';
export default async function UserList() {
const users = await getUsers();
// ...
}
This keeps boundaries clear and avoids subtle bugs.
Common Mistakes (We Made Them Too)
Assuming shared files will “just work” everywhere.
If you callfetch()or use Node APIs in a file, importing it into a Client Component will break. It’s tempting to keep all your helpers in one place, but split them by environment as your app grows.Using dynamic imports to “cheat” boundaries.
We tried to lazily import some server-only code in Client Components, thinking it would avoid errors. Didn’t work — you’ll still get runtime errors or broken builds.Not updating dependencies.
Next.js 15 depends on newer versions of React and some other ecosystem libraries. We had a stalereactversion and got mysterious errors until we aligned all versions. Always check the Next.js upgrade guide for peer dependency requirements.
Key Takeaways
- Next.js 15 is much stricter about Client vs Server boundaries. Don’t assume patterns from previous versions will work.
- Never call
fetch()or use Node APIs in Client Components, even via helpers. - Route Handlers should not be used as shared logic modules. Move business logic into
/lib/serverand import where needed. - Organize your codebase clearly:
/lib/serverfor server-only logic,/lib/clientfor client-safe helpers. - Always double-check your dependencies when upgrading. Version mismatches cause subtle, hard-to-debug issues.
Closing Thoughts
Upgrading isn’t always glamorous, but it’s how we keep our projects healthy. If you’re using React Server Components in Next.js, treat boundaries as sacred — your future self (and your team) will thank you. If you hit your own weird errors upgrading, you’re not alone. Keep going, and happy coding.
If you found this helpful, check out more programming tutorials on our blog. We cover Python, JavaScript, Java, Data Science, and more.
Top comments (0)