I’m implementing the native View Transitions API with the Next.js 16 App Router. While the transitions work perfectly for pages that don’t suspend, there’s a significant issue when navigating to a dynamic page that needs to fetch data. The Suspense fallback UI is shown during the transition, breaking the intended visual effect. This only happens in a production environment (Vercel) where there’s real network latency for data fetching; it works as expected locally after an initial page load warms the cache.
Current Behavior
In the production environment on Vercel, the behavior is different from local development. When a link on the /blogs page is clicked to navigate to /blogs/[slug], the navigation is instantaneous. The current page is immediately replaced by the fallback (the loading skeleton) for the destination route. There is no smooth, animated transition from the old content to the new.
This happens because the data for the dynamic page is not being served from the cache in production. The async Server Component suspends, and the skeleton becomes the immediate, initial render. The intended View Transition effect is completely lost, resulting in a jarring “flash of loading state” before the final blog content eventually appears.
This is in stark contrast to the local development server, which caches the page after the first load. On subsequent navigations locally, the data is served instantly from cache, and the View Transitions work perfectly as expected. The problem is isolated to the production environment’s caching behavior.
Expected Behavior
The application’s caching and prefetching should work seamlessly. When a link to /blogs/my-post enters the viewport on the /blogs page, Next.js should prefetch the data required for that page, running the database query and storing the result in its Data Cache.
When the user clicks the link, the navigation to /blogs/my-post should be near-instantaneous because the page’s data is served directly from the cache. Because the data is available immediately, the async Server Component resolves without suspending. This allows the View Transition API to capture the final, fully-rendered DOM as its “new” state, resulting in a smooth transition from the list page to the final post content, with no skeleton visible.
Relevant Code:
app/actions/blog-actions.ts
export const getBlogPostBySlug = cache(
async (slug: string): Promise<BlogPost | null> => {
"use cache";
cacheTag("blogs");
const result = await db.query<BlogPost>(
`SELECT id, title, slug, description, created_at, updated_at
FROM blogs
WHERE slug = $1
LIMIT 1`,
[slug],
);
if (result.rowCount === 0) {
return null;
}
return result.rows[0];
},
);
app/blogs/[slug]/layout.tsx
import { type ReactNode, Suspense } from "react";
import BlogPageSkeleton from "@/components/BlogPageSkeleton";
export default function BlogLayout({ children }: { children: ReactNode }) {
return <Suspense fallback={<BlogPageSkeleton />}>{children}</Suspense>;
}
app/blogs/[slug]/page.tsx
import { getBlogPostBySlug } from "@/app/actions/blog-actions";
import { notFound } from "next/navigation";
export default async function BlogPostPage({ params }) {
const blog = await getBlogPostBySlug(params.slug);
if (!blog) {
notFound();
}
return (
<article>
<h1>{blog.title}</h1>
<div>{/* ... blog content ... */}</div>
</article>
);
}
Project Information
- URLs: Github, LiveLink
- Framework: Next.js 16
- Environment: Vercel
- Project Settings: App Router, React 19, Server Components, Turbopack.
The core issue seems to be that Next.js’s default prefetching does not automatically cache the results of direct database calls. This leads to a cache miss on every navigation. What is the currently recommended pattern in Next.js 16 to explicitly opt-in these direct database queries into the Data Cache, so that they are properly prefetched and can be served instantly to prevent Suspense fallbacks during client-side navigations?