Skip to content
Drivn
6 min read

Next.js Skeleton with the App Router

Add a skeleton loading placeholder to a Next.js App Router app. Drivn ships a copy-paste, server-safe pulse that slots into loading.tsx with zero deps.

A skeleton is the grey placeholder a screen shows while its real content loads — the pulsing bars that stand in for a heading, an avatar, and a paragraph before the data arrives. In a Next.js App Router project the detail worth knowing up front is that Drivn's Skeleton is purely presentational: it holds no state and calls no browser API, so skeleton.tsx ships without a 'use client' directive and renders straight inside a Server Component — including the loading.tsx file and <Suspense> fallbacks that the App Router streams before your data resolves.

After install the component sits in src/components/ui/skeleton.tsx as a single function: a <div> whose class is bg-muted/80 rounded-md animate-skeleton merged with whatever className you pass. There is no width or height prop — size, shape, and spacing are all Tailwind utilities you hand it, so h-4 w-40 is a text line, size-12 rounded-full is an avatar, and h-40 w-full rounded-xl is a media block. The animate-skeleton utility is a two-second ease-in-out opacity pulse between 1 and 0.4, defined once in your theme.

This guide installs Drivn in a Next.js 16 project, renders a skeleton inside a Server Component, wires one up as a route-level loading.tsx fallback, and restyles the pulse from the utilities you own. Every snippet comes from the component's real API. For the full reference see the Skeleton docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Skeleton.

Install in a Next.js 16 project

Drivn installs through a small CLI that writes component source directly into your repository — there is no runtime package to version-lock. From the root of your Next.js 16 project run npx drivn add skeleton. The Skeleton imports nothing past React and the local cn utility, so the CLI writes a single skeleton.tsx file and leaves the rest of your tree untouched. The CLI reference documents every flag, including targeting a custom directory or installing several components at once. After install you own the file, and future Drivn releases will not overwrite it — commit it for a clean baseline before wiring it into a layout. Because the whole component is one <div>, there is no peer dependency to install and nothing to configure past having Tailwind v4 already processing your styles.

1# from the root of your Next.js 16 project
2npx drivn add skeleton

Render a skeleton in a Server Component

Because the Skeleton holds no state and calls no browser API, skeleton.tsx ships without a 'use client' directive — you can render it straight inside a Server Component in a Next.js App Router page, no client boundary required. Import Skeleton and give it a size through className: the component has no width or height prop, so every dimension is a Tailwind utility you pass. A single <Skeleton className="h-5 w-40" /> is a text line; stack a size-12 rounded-full avatar beside two bars and you have a profile placeholder. Because size is external, the same one-line component becomes a text row, a circular avatar, or a full media block depending only on the classes you hand it — there is nothing component-specific to learn per shape. The verbatim usage below mirrors the Skeleton docs example.

1import { Skeleton } from '@/components/ui/skeleton'
2
3export function ProfileSkeleton() {
4 return (
5 <div className="flex items-center gap-4">
6 <Skeleton className="size-12 rounded-full" />
7 <div className="flex flex-col gap-2">
8 <Skeleton className="h-5 w-40" />
9 <Skeleton className="h-4 w-60" />
10 </div>
11 </div>
12 )
13}

Use a skeleton as a loading.tsx fallback

The App Router turns a skeleton into a first-class loading state. Drop a loading.tsx file beside a page.tsx and Next.js renders it instantly while the page's async data resolves, streaming the real content in when it is ready — no client-side spinner flag to manage. Because the Skeleton is server-safe, it renders directly inside that loading.tsx. The same component works as a <Suspense fallback={...}> boundary around a single async section when you want to stream part of a page rather than the whole route. Build the fallback to mirror the shape of the real content — the same number of rows and roughly the same widths — so the layout does not jump when the data arrives and the skeleton reads as a preview rather than a generic bar. This is the pattern the App Router was designed around, and the Skeleton slots into it with no extra wiring.

1// app/dashboard/loading.tsx
2import { Skeleton } from '@/components/ui/skeleton'
3
4export default function Loading() {
5 return (
6 <div className="flex flex-col gap-3 p-6">
7 <Skeleton className="h-8 w-48" />
8 <Skeleton className="h-4 w-full" />
9 <Skeleton className="h-4 w-full" />
10 <Skeleton className="h-4 w-2/3" />
11 </div>
12 )
13}

How the pulse animation works

The entire component is a single <div> — there is no state, no effect, and no variant map. The function spreads your props onto the div and merges its base class with any className through cn(). That base is bg-muted/80 rounded-md animate-skeleton: bg-muted/80 tints the placeholder with the muted theme token at 80% opacity, rounded-md softens the corners, and animate-skeleton runs the pulse. The animation is defined once in your theme as skeleton 2s ease-in-out infinite, and its keyframes fade the element between full opacity and 0.4 and back, so every skeleton on the page pulses in sync. Because the tint reads the --muted token, the placeholder matches your palette in dark and light mode with no override. The verbatim source below is the whole component.

1// verbatim from skeleton.tsx
2export function Skeleton({
3 className,
4 ...props
5}: React.HTMLAttributes<HTMLDivElement>) {
6 return (
7 <div
8 className={cn(
9 'bg-muted/80 rounded-md animate-skeleton',
10 className
11 )}
12 {...props}
13 />
14 )
15}

Restyle and compose the skeleton

Because the placeholder is a plain <div> tinted by bg-muted/80, restyling it is either a one-line edit to the base class or a per-instance className. Slow the pulse by pointing --animate-skeleton at a longer duration in your theme; sharpen the corners with rounded-none or round them fully with rounded-full; or brighten the tint by swapping bg-muted/80 for a lighter step. The className you pass merges after the base through cn(), so one call site can reshape a single skeleton without touching the shared file. To build a reusable placeholder, compose several <Skeleton> elements into a named component — a CardSkeleton or a TableRowSkeleton — that mirrors the real component it stands in for, then render that wherever the data is loading. Since the tint reads the --muted token, adjusting it in theming re-themes every skeleton across the app at once. For copy-paste placeholder layouts see the Skeleton examples.

1// slower pulse — in your theme
2--animate-skeleton: skeleton 3s ease-in-out infinite;
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

Yes. The Skeleton holds no state and calls no browser API — it is a styled <div> that merges a base class with any className you pass. Its source ships without a 'use client' directive, so it renders straight inside a Server Component in the Next.js App Router, including a route's loading.tsx file and <Suspense> fallbacks. It works the same in any client-rendered React app; nothing about it forces a client boundary.

Through className. The Skeleton has no width or height prop — size, shape, and spacing are all Tailwind utilities you hand it. Pass h-4 w-40 for a text line, size-12 rounded-full for a circular avatar, or h-40 w-full rounded-xl for a media block. Because dimensions live outside the component, one <div>-based primitive covers every placeholder shape without a per-shape prop.

Create a loading.tsx file beside your page.tsx and return a layout of <Skeleton> elements from it. Next.js renders that file instantly while the page's async data resolves and streams the real content in when it is ready, so you never manage a client-side loading flag. For a single async section rather than a whole route, wrap it in <Suspense fallback={<YourSkeleton />}>. Mirror the real content's shape so the layout does not shift when data arrives.

Yes. The animation is the animate-skeleton utility, defined once in your theme as skeleton 2s ease-in-out infinite — point --animate-skeleton at a longer duration to slow it. The tint is bg-muted/80, so recolor it by editing that class in the base or with a per-instance className, which merges after the base through cn(). Because the default reads the --muted theme token, retheming that value updates every skeleton for dark and light mode at once.

Yes. The component has no dependency on Next.js or any router — it is a self-contained styled <div> with no state, no context, and no browser API, so it works anywhere React and Tailwind reach the DOM. In a Vite + React app there is no server boundary to think about; run npx drivn add skeleton and render it the same way, sizing each placeholder with Tailwind utilities through className.