{"slug": "skeleton-loading-screens-in-next-js-app-router-the-right-way-to-handle-async-ui", "title": "Skeleton Loading Screens in Next.js App Router — The Right Way to Handle Async UI", "summary": "A developer building Pixova's free AI image generator shares best practices for implementing skeleton loading screens in Next.js App Router. The approach uses React Suspense with loading.js or inline boundaries, matching skeleton dimensions exactly to prevent layout shift, and recommends showing nothing for loads under 200ms.", "body_md": "Skeleton screens are one of those things that seem simple until you actually implement them well. The basic idea is straightforward: show a placeholder shaped like the content while it loads. The execution has a lot of ways to go wrong.\n\nHere's what actually works in Next.js App Router, from the patterns I've landed on after a lot of iteration building [free AI image generator high quality](https://pixova.io/blog/free-ai-image-generator-high-quality) where loading states are visible on almost every interaction.\n\nA spinner communicates \"something is happening.\" A skeleton communicates \"here's roughly what you're about to see.\" That distinction matters more than it sounds.\n\nUsers who see a skeleton can start mentally orienting to the layout before content arrives. They're not staring at an empty space trying to remember what was supposed to appear there. The cognitive load is lower, and the perceived wait time is shorter — not because the content actually loads faster, but because the user's brain is doing useful work during the wait.\n\nThe exception: if content will arrive in under 200ms, show nothing. A skeleton that flashes briefly is more disorienting than just waiting for the content.\n\nApp Router's native approach uses React Suspense with a `loading.js`\n\nfile or inline Suspense boundaries:\n\n```\n// app/dashboard/loading.js — automatic Suspense wrapper\nexport default function DashboardLoading() {\n  return <DashboardSkeleton />;\n}\njs\n// Inline Suspense for granular control\nimport { Suspense } from 'react';\n\nexport default function Page() {\n  return (\n    <div>\n      <h1>Dashboard</h1>\n      <Suspense fallback={<StatsSkeleton />}>\n        <Stats />\n      </Suspense>\n      <Suspense fallback={<FeedSkeleton />}>\n        <ActivityFeed />\n      </Suspense>\n    </div>\n  );\n}\n```\n\nThe inline approach lets different sections load independently — the stats can show while the activity feed is still loading. This is often better UX than waiting for everything.\n\nThe skeleton needs to match the real component's dimensions exactly. This is where most implementations go wrong — a skeleton that's 40px shorter than the content it replaces causes layout shift when content loads.\n\n```\n// The real component\nfunction UserCard({ user }) {\n  return (\n    <div className=\"flex items-center gap-3 p-4 rounded-xl border border-border\">\n      <img \n        src={user.avatar} \n        alt={user.name}\n        className=\"w-10 h-10 rounded-full\"\n      />\n      <div className=\"flex flex-col\">\n        <span className=\"text-sm font-medium text-foreground\">{user.name}</span>\n        <span className=\"text-xs text-muted\">{user.email}</span>\n      </div>\n    </div>\n  );\n}\n\n// The skeleton — identical structure, shimmer instead of content\nfunction UserCardSkeleton() {\n  return (\n    <div className=\"flex items-center gap-3 p-4 rounded-xl border border-border\">\n      <div className=\"w-10 h-10 rounded-full bg-neutral-200 dark:bg-neutral-700 animate-pulse\" />\n      <div className=\"flex flex-col gap-1.5\">\n        <div className=\"h-4 w-32 rounded bg-neutral-200 dark:bg-neutral-700 animate-pulse\" />\n        <div className=\"h-3 w-24 rounded bg-neutral-200 dark:bg-neutral-700 animate-pulse\" />\n      </div>\n    </div>\n  );\n}\n```\n\nKey things to match exactly:\n\n`p-4`\n\nand `gap-3`\n\n)`w-10 h-10`\n\nfor the avatar, specific widths for text lines)`animate-pulse`\n\nfrom Tailwind is the quickest approach — it fades the element opacity up and down. For a more polished shimmer effect:\n\n```\n/* globals.css */\n@keyframes shimmer {\n  0% { background-position: -200% 0; }\n  100% { background-position: 200% 0; }\n}\n\n.skeleton-shimmer {\n  background: linear-gradient(\n    90deg,\n    rgb(var(--skeleton-base)) 25%,\n    rgb(var(--skeleton-highlight)) 50%,\n    rgb(var(--skeleton-base)) 75%\n  );\n  background-size: 200% 100%;\n  animation: shimmer 1.5s infinite;\n}\n/* In your theme */\n:root {\n  --skeleton-base: 229 231 235;      /* neutral-200 */\n  --skeleton-highlight: 243 244 246; /* neutral-100 */\n}\n\n.dark {\n  --skeleton-base: 38 38 38;         /* neutral-800 */\n  --skeleton-highlight: 55 55 55;    /* neutral-700 */\n}\n```\n\nThe directional shimmer feels more intentional than the pulse — it suggests something is actively loading rather than just waiting.\n\nWhen you don't know how many items will load, skeletons need a sensible count. I use three as the default:\n\n```\nfunction ListSkeleton({ count = 3 }) {\n  return (\n    <div className=\"flex flex-col gap-3\">\n      {Array.from({ length: count }).map((_, i) => (\n        <ItemSkeleton key={i} />\n      ))}\n    </div>\n  );\n}\n```\n\nPick a number that represents a typical result set for your content. If users usually see 8-10 items, show 8 skeleton items. Showing 3 when 10 arrive causes a larger layout shift than showing a count closer to the actual result.\n\nOne subtle issue: if data loads very fast (under 200ms), the user sees the skeleton flash briefly before content appears. This can actually feel worse than no skeleton.\n\n``` js\n'use client';\nimport { useState, useEffect } from 'react';\n\nfunction useDeferredLoading(isLoading, delay = 200) {\n  const [showSkeleton, setShowSkeleton] = useState(false);\n\n  useEffect(() => {\n    if (!isLoading) {\n      setShowSkeleton(false);\n      return;\n    }\n\n    const timer = setTimeout(() => {\n      setShowSkeleton(true);\n    }, delay);\n\n    return () => clearTimeout(timer);\n  }, [isLoading, delay]);\n\n  return showSkeleton;\n}\n\n// Usage\nfunction Component() {\n  const { data, isLoading } = useSomeData();\n  const showSkeleton = useDeferredLoading(isLoading);\n\n  if (showSkeleton) return <ComponentSkeleton />;\n  if (!data) return null;\n  return <ComponentContent data={data} />;\n}\n```\n\nThis only shows the skeleton if loading takes longer than 200ms. Fast loads show nothing, and the content just appears. Slower loads get the skeleton treatment.\n\nFor larger applications, a shared skeleton primitive keeps things consistent:\n\n```\n// components/ui/Skeleton.jsx\nfunction Skeleton({ className, ...props }) {\n  return (\n    <div\n      className={cn(\n        \"animate-pulse rounded-md bg-neutral-200 dark:bg-neutral-700\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\n// Usage in feature skeletons\nfunction ProductCardSkeleton() {\n  return (\n    <div className=\"space-y-3 p-4\">\n      <Skeleton className=\"h-48 w-full rounded-xl\" />\n      <Skeleton className=\"h-4 w-3/4\" />\n      <Skeleton className=\"h-3 w-1/2\" />\n      <div className=\"flex gap-2\">\n        <Skeleton className=\"h-8 w-20\" />\n        <Skeleton className=\"h-8 w-16\" />\n      </div>\n    </div>\n  );\n}\n```\n\nThe `cn`\n\nutility (clsx + tailwind-merge) handles the class merging. This pattern makes it easy to compose skeletons from a consistent primitive.\n\nThe skeleton patterns above are running in production handling the generation loading state at pixova.io — the placeholder appears at the exact dimensions of the output image (aspect ratio selected before generation), so there's zero layout shift when the image arrives. The deferred loading hook prevents a flash when generation completes faster than expected.\n\nQuestions on specific skeleton scenarios? Comments open.\n\n**Matching width but not height.** Text lines in skeletons are often set to `h-4`\n\n(16px) but the actual rendered text at that font size is more like 20px with line height. Measure the real component, don't estimate.\n\n**Forgetting dark mode.** `bg-neutral-200`\n\nlooks fine in light mode and nearly invisible against a dark background. Always add the dark mode variant: `bg-neutral-200 dark:bg-neutral-700`\n\n.\n\n**Using skeletons for errors.** A skeleton that never resolves because there's an error underneath is confusing — users sit watching a shimmer that will never become content. Always have a clear error state that replaces the skeleton when something fails.\n\n**Too many simultaneous animations.** A page with 20 elements all pulsing at slightly different rates creates visual noise. Either sync the animation timing or use a single shimmer direction across all skeletons so they feel coordinated.\n\n**Skeletons that don't match responsive behavior.** Your card might be full-width on mobile and 50% on desktop. Your skeleton should match both. Use the same responsive classes on the skeleton wrapper that you use on the real component.\n\nThrottle your network in DevTools (Network tab → No throttling dropdown → Slow 3G) and reload pages with your skeleton implementation. Things to check:\n\nSlow 3G throttling surfaces layout shift issues that you'd miss at normal speeds. Worth doing before shipping any new skeleton implementation.", "url": "https://wpnews.pro/news/skeleton-loading-screens-in-next-js-app-router-the-right-way-to-handle-async-ui", "canonical_source": "https://dev.to/aon_infotech_3a1b6ff525fc/skeleton-loading-screens-in-nextjs-app-router-the-right-way-to-handle-async-ui-3hj1", "published_at": "2026-06-27 05:47:23+00:00", "updated_at": "2026-06-27 06:34:08.904960+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Next.js", "React Suspense", "Tailwind CSS", "Pixova"], "alternates": {"html": "https://wpnews.pro/news/skeleton-loading-screens-in-next-js-app-router-the-right-way-to-handle-async-ui", "markdown": "https://wpnews.pro/news/skeleton-loading-screens-in-next-js-app-router-the-right-way-to-handle-async-ui.md", "text": "https://wpnews.pro/news/skeleton-loading-screens-in-next-js-app-router-the-right-way-to-handle-async-ui.txt", "jsonld": "https://wpnews.pro/news/skeleton-loading-screens-in-next-js-app-router-the-right-way-to-handle-async-ui.jsonld"}}