Documentation

Routing

Loading states

Show a top progress bar for route navigation, section skeletons for loading content, and button spinners for actions in VitNode.

VitNode gives every loading moment one indicator: a top progress bar for route navigation, a skeleton for a section that is still loading, and a spinner inside the button that started an action.

During a client-side navigation, a slim bar moves across the top of the viewport while the current page, header, and sidebar stay on screen and usable. NavigationProgress from @vitnode/core/tanstack/pending draws it, and VitNodeRootProviders already mounts it, so an app gets it without extra code.

The bar follows the router's own lifecycle:

  • It appears only when a navigation lasts longer than 150 ms, so fast navigations never flash.
  • It finishes when the router commits the destination, including an error page, a not-found page, or the target of a redirect.
  • A newer navigation that replaces a pending one keeps the same bar moving instead of restarting it.
  • Preloading on hover, router.invalidate(), and TanStack Query refetches never start it.
  • With prefers-reduced-motion, it fades in and out at full width instead of moving.

To keep the current page visible, the router never swaps it for a pending component during a navigation:

apps/web/src/router.tsx
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { RoutePendingSpinner } from '@vitnode/core/tanstack/pending'

export function getRouter() {
  return createTanStackRouter({
    routeTree,
    defaultPendingComponent: RoutePendingSpinner,
    defaultPendingMs: Infinity,
  })
}

defaultPendingComponent still matters: it is the Suspense fallback of a route that has no pendingComponent of its own.

Leave pendingMs alone

Setting pendingMs on a route brings back the old behavior: the page you are leaving is replaced by a skeleton while the bar is also running. Use a section skeleton instead.

To show your own UI during a navigation, read the same state the bar uses with useIsRouteNavigating().

Component skeletons with Suspense

For feature pages with multiple asynchronous queries, wrap content in React <Suspense> using VitNode's pre-built pending skeletons:

plugins/site-notes/src/pages/notes-page.tsx
import { FeedPendingSkeleton } from '@vitnode/core/tanstack/pending'
import { Suspense } from 'react'

const NotesList = () => {
  // Asynchronously fetched notes
  return <div>Notes List</div>
}

const NotesPage = () => {
  return (
    <div className="container mx-auto max-w-3xl p-4">
      <Suspense fallback={<FeedPendingSkeleton rows={4} />}>
        <NotesList />
      </Suspense>
    </div>
  )
}

export default NotesPage

Pre-built skeleton shapes

Import pre-built skeletons directly from @vitnode/core/tanstack/pending:

Skeleton shapeLayout patternTypical use case
FeedPendingSkeletonCard timeline with avatars and text rowsActivity feeds, note lists, blog posts
TablePendingSkeletonSearch toolbar, column headers, and rowsAdminCP data tables, member directories
CardsPendingSkeletonResponsive 1/2/3 column card gridDashboard widgets, product catalogs
FormPendingSkeletonStacked input placeholders and action buttonsSettings forms, modal dialogs
AuthPendingSkeletonCentered authentication card with form inputsSign-in, sign-up, password reset
ProfilePendingSkeletonCover, avatar, name and side cardsMember profiles
DiscoverPendingSkeletonHeading and a timeline of resultsDiscover-style activity pages
SearchPendingSkeletonSearch box, filter column and resultsSearch pages
BreadcrumbPendingSkeletonTwo short crumb placeholdersA breadcrumb that is still loading
RoutePendingSkeletonHeading and text blocksA page with no closer shape
RoutePendingSpinnerCentered accessible spinnerSuspense fallback with no known shape

Customizing skeleton props

The table, feed, cards, form, auth and route skeletons accept every prop of RoutePendingSkeletonProps. The profile, discover, search and breadcrumb skeletons accept only className and label.

plugins/site-notes/src/pages/notes-admin-page.tsx
import { TablePendingSkeleton } from '@vitnode/core/tanstack/pending'
import { Suspense } from 'react'

// `AdminNotesTable` is your table, which suspends while its query loads.
const AdminNotesPage = () => {
  return (
    <Suspense
      fallback={
        <TablePendingSkeleton
          className="p-6"
          rows={8}
          withDescription={false}
        />
      }
    >
      <AdminNotesTable />
    </Suspense>
  )
}

export default AdminNotesPage
PropTypeDefaultDescription
rowsnumberper shape (2 to 6)Number of skeleton items, rows, or cards to display
withDescriptionbooleantrueWhether to display a skeleton subtitle under the heading
classNamestringundefinedAdditional Tailwind utility classes merged onto the container
labelstring'Loading'Screen-reader status text, rendered as a visually hidden role="status" element. Pass a translated string

Keep data on screen during refetches

A background refetch should never blank content that is already loaded. Show a skeleton only for the first load, which is isPending, never isFetching. For a query keyed on a search term, a filter, or a page, keep the previous result while the next one loads and mark it as busy:

plugins/site-notes/src/views/notes-search.tsx
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { FeedPendingSkeleton } from '@vitnode/core/tanstack/pending'
import { cn } from 'cn'

// `notesQueryOptions` is your own query helper, keyed on the search term.
const NotesSearch = ({ search }: { search: string }) => {
  const query = useQuery({
    ...notesQueryOptions({ search }),
    placeholderData: keepPreviousData,
  })

  if (query.isPending) return <FeedPendingSkeleton withDescription={false} />

  return (
    <ul
      aria-busy={query.isFetching}
      className={cn(query.isPlaceholderData && 'opacity-60')}
    >
      {query.data?.map(note => <li key={note.id}>{note.title}</li>)}
    </ul>
  )
}

Show action progress on the button

Submissions and mutations show their progress on the control that started them. Pass isLoading to Button: it disables the button, swaps the label for a spinner without changing the width, and sets aria-busy. AutoForm and ConfirmAction already do this for their submit buttons.

plugins/site-notes/src/views/delete-note-button.tsx
import { Button } from '@vitnode/core/components/ui/button'
import { useMutation } from '@tanstack/react-query'

// `deleteNote` is your own API call. In a real handler, also invalidate the
// notes query and show a toast in `onSuccess`.
const DeleteNoteButton = ({ id }: { id: number }) => {
  const mutation = useMutation({ mutationFn: deleteNote })

  return (
    <Button
      isLoading={mutation.isPending}
      onClick={() => mutation.mutate(id)}
      variant="destructive"
    >
      Delete
    </Button>
  )
}

While the action runs, also disable any other control that would conflict with it, such as Reject next to a pending Approve.