Next.js: View Transitions vs. Suspense Caching

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?