Next redirect 441 in a Cache Components admin

Soft nav to Movements crashed with React error 441 when a server redirect streamed inside Suspense. I render the catalogue index on the client instead.

Sander Korf4 min read

On a Next.js admin for a clinician movement catalogue backed by Convex, operators edit movement families and flows in a master-detail shell. The Movements index used to live at /admin/catalogue and immediately redirect to the first family so the sidebar never showed an empty state.

Soft navigation from Decision trees into Movements worked in dev. In production with Cache Components enabled, the page threw Minified React error #441 and the route error boundary showed Try again. A full reload sometimes masked it because the redirect completed before the client boundary fired.

The Movements catalogue is the admin list of movement families (groups). Each group owns flows the clinician-facing product consumes. Soft navigation is a client-side route change without a full document reload, which is how operators hop between admin sections all day.

Redirect inside a Suspense hole

The server page called preloadQuery for catalogue groups, then redirect() to /admin/catalogue?group=<firstId> when no group query param was present. Under Cache Components that redirect streams as NEXT_REDIRECT inside a Suspense boundary. The admin error.tsx treated it like any other thrown value and rendered the recovery UI.

Restyling the error page would hide the symptom. A catch-all that swallows every error would also eat real failures and notFound() signals I still want to bubble.

Full document loads sometimes completed the redirect before the client error boundary mounted, which is why the bug felt flaky in Slack threads. Soft nav kept the shell alive and left the Suspense hole on screen long enough for React to treat the redirect throw as a render failure.

Render the index, pick the family on the client

I stopped redirecting from the catalogue index route. The page still preloads groups with Convex, then a client picker chooses which family to show. pickCatalogueGroup returns the match when groupId is defined, otherwise groups[0].

export function pickCatalogueGroup<T extends { _id: string }>(groups: T[], groupId?: string) {
	if (groupId !== undefined) return groups.find((g) => g._id === groupId);
	return groups[0];
}

Deep links with ?group= keep working. First visit without a param lands on the first family without a server round trip through redirect machinery.

The sidebar still lists every family. Selecting one updates the query param so refresh and share links stay stable. Convex preloadQuery on the server fills the initial group list; the client picker only chooses which row is active in the master-detail layout. No second fetch for the redirect hop.

When the catalogue is empty, the page shows an honest empty state instead of redirecting into a 404. That edge case mattered during seed migrations when groups existed in staging but not production yet.

Let real redirects escape error.tsx

Admin error boundaries now call unstable_rethrow(error) before painting Try again. Framework redirects and notFound() still propagate. Only genuine crashes get the reset button.

export default function AdminError({ error, reset }: { error: Error; reset: () => void }) {
	unstable_rethrow(error);
	return (
		<div>
			<p>Something broke in the admin shell.</p>
			<button type='button' onClick={reset}>
				Try again
			</button>
		</div>
	);
}

I also wrapped useSearchParams consumers in Suspense where production would bail the same way. Cache Components and client hooks share the rule: do not assume a server redirect is invisible to the nearest error boundary.

Other admin routes that used the same pattern (preload, then immediate redirect to a default child) got the same treatment: render something useful on the index route, push default selection to the client, rethrow framework errors at the top of error.tsx. Decision trees and flow editors were already client-heavy, so Movements was the loudest failure first.

Production soft nav into Movements now lands on a populated master-detail view. Error #441 was the redirect wearing a crash costume.


Happy coding! Sander