npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tinloof/sanity-next

v2.0.2

Published

Sanity & Next.js utilities and components package

Readme

@tinloof/sanity-next

A comprehensive collection of Next.js utilities, components, and hooks for seamless Sanity integration. This package provides everything you need to build modern, performant Next.js applications with Sanity CMS, including internationalization support, metadata resolution, image optimization, infinite scroll, and more.

Installation

pnpm install @tinloof/sanity-next

Table of Contents

Quick Start

The initSanity function provides a complete setup for your Next.js application with sensible defaults:

// lib/sanity/index.ts
import { initSanity } from "@tinloof/sanity-next";

export const {
  client,
  clientWithToken,
  sanityFetch,
  SanityImage,
  resolveSanityMetadata,
  defineEnableDraftMode,
  redirectIfNeeded,
  generateSitemap,
} = initSanity();

Configuration

Environment Variables

Create a .env.local file with the following required variables:

NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_TOKEN=your-viewer-token
SANITY_API_VERSION=2025-01-01

Basic Setup

import { initSanity } from "@tinloof/sanity-next";

export const {
  client,
  clientWithToken,
  sanityFetch,
  SanityImage,
  resolveSanityMetadata,
  generateSitemap,
  defineEnableDraftMode,
  redirectIfNeeded,
} = initSanity({
  baseUrl: "https://yoursite.com",
  client: {
    // Override default client config
    apiVersion: "2024-01-01",
    useCdn: true,
  },
});

With Internationalization

import { initSanity } from "@tinloof/sanity-next";

const i18nConfig = {
  locales: [
    { id: "en", title: "English" },
    { id: "fr", title: "Français" },
    { id: "es", title: "Español" },
  ],
  defaultLocaleId: "en",
};

export const {
  client,
  sanityFetch,
  SanityImage,
  resolveSanityMetadata,
  generateSitemap,
  localizePathname,
} = initSanity({
  i18n: i18nConfig,
});

Draft Mode

The package provides a streamlined way to implement Sanity's draft mode in Next.js App Router projects. The draft mode handler is automatically configured when you initialize Sanity with a token.

Create your draft mode API route:

// app/api/draft/route.ts
import { defineEnableDraftMode } from "@/lib/sanity";

export { defineEnableDraftMode as GET };

The defineEnableDraftMode handler:

  • Automatically uses the token from your initialization
  • Returns helpful error messages if not properly configured
  • Handles all the draft mode setup internally

Components

SanityImage

An optimized image component that automatically generates responsive images with proper srcsets, LQIP support, and focal point positioning.

import { SanityImage } from "@/lib/sanity";

export default function MyComponent({ image }) {
  return (
    <SanityImage
      data={image}
      aspectRatio="16/9"
      sizes="(min-width: 768px) 50vw, 100vw"
      lqip={true}
      fetchPriority="high"
      className="rounded-lg"
    />
  );
}

Props

| Prop | Type | Description | | --------------- | -------------------------------------- | ------------------------------------------------------------------- | | data | SanityImage \| null | The Sanity image object from your CMS | | aspectRatio | string (optional) | Aspect ratio in width/height format (e.g., "16/9", "4/3") | | sizes | string (optional) | Responsive sizes attribute for optimal image loading | | lqip | boolean (optional, default: false) | Enable Low Quality Image Placeholder for smoother loading | | fetchPriority | "high" \| "default" (optional) | Fetch priority for the image (use "high" for above-the-fold images) | | className | string (optional) | CSS class name |

Features

  • Automatic responsive images: Generates optimal srcsets for all screen sizes
  • LQIP support: Shows blurred placeholder while high-quality image loads
  • Focal point support: Respects Sanity's hotspot and crop settings
  • Format optimization: Automatically serves modern formats (WebP, AVIF) when supported
  • Performance optimized: Lazy loading by default, with eager loading for high-priority images

ExitPreview

A client component for exiting Sanity's draft mode with a clean, accessible interface.

// app/layout.tsx
import { ExitPreview } from "@tinloof/sanity-next/components/exit-preview";
import { disableDraftMode } from "@tinloof/sanity-next/actions/disable-draft-mode";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <ExitPreview disableDraftMode={disableDraftMode} />
      </body>
    </html>
  );
}

Props

| Prop | Type | Description | | ------------------ | -------------------------------- | --------------------------------------------- | | disableDraftMode | () => Promise<void> | Server action to disable draft mode | | className | string (optional) | Custom CSS class (disables default styling) | | styles | React.CSSProperties (optional) | Additional inline styles merged with defaults |

Features

  • Smart visibility: Only shows when not in Sanity's Presentation Tool
  • Loading state: Shows feedback while disabling draft mode
  • Auto-refresh: Refreshes the page after successful disable

InfiniteScroll

An opinionated render-props component for infinite scrolling with Sanity data. Combines useInfiniteQuery and useInView for automatic or manual infinite loading with sensible defaults.

Query Structure

Your GROQ query should be structured to support pagination with the following params:

  • $pageStart - The starting index (0-based)
  • $pageEnd - The ending index (exclusive)
  • $pageNumber - The current page number (0-based)
  • $entriesPerPage - The number of entries per page

The query result should include:

  • An array of entries (default key: "entries", configurable via entriesKey)
  • A total count (default key: "entriesCount", configurable via countKey)
{
  "entries": *[_type == "post"] | order(publishedAt desc) [$pageStart...$pageEnd] {
    _id,
    title,
    slug,
    publishedAt
  },
  "entriesCount": count(*[_type == "post"])
}

With filters:

{
  "entries": *[_type == "post" && ($filterTag == null || $filterTag in tags[]->slug.current)]
    | order(publishedAt desc) [$pageStart...$pageEnd] {
    _id,
    title,
    slug
  },
  "entriesCount": count(*[_type == "post" && ($filterTag == null || $filterTag in tags[]->slug.current)])
}

Basic Usage

"use client";

import { InfiniteScroll } from "@tinloof/sanity-next/components/infinite-scroll";
import { client } from "@/lib/sanity";

export function BlogList({ initialData }) {
  return (
    <InfiniteScroll
      client={client}
      query={POSTS_QUERY}
      initialData={initialData}
      pageSize={10}
    >
      {({ data, hasMore, ref }) => (
        <>
          <div className="grid gap-4">
            {data?.entries?.map((post) => (
              <article key={post._id}>
                <h2>{post.title}</h2>
              </article>
            ))}
          </div>
          {hasMore && <div ref={ref}>Loading more...</div>}
        </>
      )}
    </InfiniteScroll>
  );
}

With Additional Params

<InfiniteScroll
  client={client}
  query={BLOG_INDEX_QUERY}
  initialData={initialData}
  pageSize={10}
  params={{ filterTag: tagParam ?? null }}
>
  {({ data, hasMore, loadMore }) => (
    <>
      <div className="grid">
        {data?.entries?.map((post) => (
          <PostCard key={post._id} post={post} />
        ))}
      </div>
      {hasMore && <button onClick={loadMore}>Load More</button>}
    </>
  )}
</InfiniteScroll>

With Draft Mode Support

<InfiniteScroll
  client={client}
  query={BLOG_INDEX_QUERY}
  initialData={initialData}
  pageSize={10}
  draftPageSize={500}
  params={{ filterTag: tagParam ?? null }}
>
  {({ data, hasMore, ref }) => (
    // ...
  )}
</InfiniteScroll>

Props

| Prop | Type | Description | | --------------------- | ------------------------------------- | ---------------------------------------------------------- | | client | SanityClient | The Sanity client instance | | query | string | The GROQ query to execute | | initialData | T | Initial data for SSR hydration (first page) | | pageSize | number | Number of entries per page | | params | Record<string, unknown> (optional) | Additional query params (merged with pagination params) | | draftPageSize | number (optional, default: 500) | Page size for draft/preview mode (fetches all at once) | | entriesKey | string (optional, default: "entries") | Key in query result containing the entries array | | countKey | string (optional, default: "entriesCount") | Key in query result containing the total count | | hasMore | (pages) => boolean (optional) | Custom function to determine if more pages exist | | children | (renderProps) => ReactNode | Render function receiving scroll state | | autoLoad | boolean (optional, default: true) | Auto-load when trigger element is in view | | intersectionOptions | IntersectionObserverInit (optional) | Options for the intersection observer | | swrOptions | SWRInfiniteConfiguration (optional) | SWR configuration options |

InfiniteScrollBase

For advanced use cases requiring full control over params, select, and hasMore logic, use InfiniteScrollBase:

import { InfiniteScrollBase } from "@tinloof/sanity-next/components/infinite-scroll-base";

<InfiniteScrollBase
  client={client}
  query={POSTS_QUERY}
  initialData={initialData}
  params={({ previousPageData }, { paginationParams }) => {
    if (previousPageData?.entries?.length < pageSize) return null;
    return {
      ...paginationParams({ pageSize }),
      filterTag: tagParam ?? null,
    };
  }}
  select={(pages, { mergePages }) => mergePages(pages)}
  hasMore={(pages) => {
    const allEntries = pages.flatMap((p) => p?.entries ?? []);
    const lastPage = pages[pages.length - 1];
    return allEntries.length < (lastPage?.entriesCount ?? 0);
  }}
>
  {({ data, hasMore, ref }) => (
    // ...
  )}
</InfiniteScrollBase>

Render Props

| Property | Type | Description | | -------------- | --------------------- | ---------------------------------------------- | | data | T \| undefined | The merged/selected data from all loaded pages | | isLoading | boolean | Whether the first page is loading | | isValidating | boolean | Whether any page is currently being fetched | | hasMore | boolean | Whether there are more pages to load | | loadMore | () => void | Function to manually trigger loading more | | ref | Ref<HTMLDivElement> | Ref to attach to trigger element | | inView | boolean | Whether the trigger element is in view |

Hooks

useInfiniteQuery

A powerful hook for infinite loading of Sanity data using SWR. Types are automatically inferred from the query when using Sanity's typegen.

"use client";

import { useInfiniteQuery } from "@tinloof/sanity-next/hooks";
import { client } from "@/lib/sanity";

const BLOG_QUERY = `{
  "entries": *[_type == "post"] | order(publishedAt desc) [$pageStart...$pageEnd] {
    _id,
    title
  },
  "entriesCount": count(*[_type == "post"])
}`;

export function BlogIndex({ initialData }) {
  const pageSize = 10;

  const { data, loadMore, hasMore, isValidating } = useInfiniteQuery({
    client,
    query: BLOG_QUERY,
    initialData,
    params: ({ pageIndex, previousPageData }, { paginationParams }) => {
      // Stop pagination when previous page has fewer entries than page size
      if (previousPageData?.entries && previousPageData.entries.length < pageSize) {
        return null;
      }
      return paginationParams({ pageSize });
    },
    select: (pages, { mergePages }) => mergePages(pages),
  });

  return (
    <div>
      {data?.entries?.map((post) => (
        <article key={post._id}>{post.title}</article>
      ))}
      {hasMore && (
        <button onClick={loadMore} disabled={isValidating}>
          {isValidating ? "Loading..." : "Load More"}
        </button>
      )}
    </div>
  );
}

Props

| Prop | Type | Description | | ------------- | ------------------------------------ | ------------------------------------------------ | | client | SanityClient | The Sanity client instance | | query | string | The GROQ query to execute | | initialData | T (optional) | Initial data for SSR hydration (first page) | | params | (state, helpers) => params \| null | Function to get params for each page | | select | (pages, helpers) => T | Function to transform/merge pages | | hasMore | (pages) => boolean (optional) | Custom callback to determine if more pages exist | | swrOptions | SWRInfiniteConfiguration (optional)| SWR configuration options |

Params Function

The params function receives:

  • state.pageIndex: Current page index (0-based)
  • state.previousPageData: Data from the previous page, or null for the first page
  • helpers.paginationParams({ pageSize }): Helper that generates pageStart, pageEnd, pageNumber, and entriesPerPage

Return null to stop fetching more pages.

Select Function

The select function receives:

  • pages: Array of all fetched pages
  • helpers.mergePages(pages, config?): Helper that merges pages by concatenating entries arrays

Returns

| Property | Type | Description | | -------------- | --------------------- | -------------------------------------- | | data | T \| undefined | The selected/merged data | | pages | T[] | Raw array of all fetched pages | | isLoading | boolean | Whether the first page is loading | | isValidating | boolean | Whether any page is being fetched | | hasMore | boolean | Whether there are more pages to load | | loadMore | () => void | Function to load the next page | | size | number | Number of pages currently loaded | | setSize | (size) => void | Function to set the number of pages |

Example: Manual Pagination (without helpers)

const { data, loadMore } = useInfiniteQuery({
  client,
  query: `*[_type == "post"] | order(publishedAt desc) [$pageStart...$pageEnd] { _id, title }`,
  params: ({ pageIndex }) => ({
    pageStart: pageIndex * 10,
    pageEnd: pageIndex * 10 + 10,
  }),
  select: (pages) => {
    const validPages = pages.filter(Boolean);
    return validPages.flat();
  },
});

Example: Custom hasMore Logic

const { data, hasMore } = useInfiniteQuery({
  client,
  query: POSTS_QUERY,
  params: ({ pageIndex }, { paginationParams }) => paginationParams({ pageSize: 12 }),
  select: (pages, { mergePages }) => mergePages(pages, { entriesKey: "posts" }),
  hasMore: (pages) => {
    const allPosts = pages.flatMap((p) => p?.posts ?? []);
    const lastPage = pages[pages.length - 1];
    return allPosts.length < (lastPage?.totalPosts ?? 0);
  },
});

useInView

A lightweight React hook wrapper around the Intersection Observer API. Detects when an element enters the viewport.

"use client";

import { useInView } from "@tinloof/sanity-next/hooks";

export function LazySection() {
  const { inView, ref } = useInView({
    threshold: 0.5,
    rootMargin: "100px",
  });

  return (
    <div ref={ref}>
      {inView ? <ExpensiveComponent /> : <div>Scroll to load...</div>}
    </div>
  );
}

Props

| Prop | Type | Description | | ------------ | ---------------------------- | -------------------------------------------------------- | | root | Element \| null (optional) | The element used as the viewport for checking visibility | | rootMargin | string (optional) | Margin around the root (e.g., "10px 20px 30px 40px") | | threshold | number (optional) | Number between 0 and 1 indicating visibility percentage |

Returns

| Property | Type | Description | | -------- | --------------------- | --------------------------------------- | | inView | boolean | Whether the element is in the viewport | | ref | Ref<HTMLDivElement> | Ref to attach to the element to observe |

Utils

Metadata Resolution

Generate comprehensive Next.js metadata from Sanity content, including SEO tags, Open Graph images, and internationalization support.

// app/[slug]/page.tsx
import { resolveSanityMetadata } from "@/lib/sanity";
import { loadPage } from "@/lib/sanity/queries";
import type { ResolvedMetadata } from "next";

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string; locale: string }> },
  parentPromise: Promise<ResolvedMetadata>
) {
  const parent = await parentPromise;
  const { slug, locale } = await params;

  const data = await loadPage({ slug, locale });

  if (!data) return {};

  return resolveSanityMetadata({
    parent,
    title: data.title,
    seo: data.seo,
    pathname: data.pathname,
    locale,
    translations: data.translations,
  });
}

Props

| Prop | Type | Description | | -------------- | --------------------------- | ----------------------------------------- | | parent | ResolvedMetadata | Parent metadata from Next.js | | title | string (optional) | Page title | | seo | object (optional) | SEO configuration object | | pathname | string\|object (optional) | Page pathname or slug object | | locale | string (optional) | Current locale | | translations | array (optional) | Array of translation objects for hreflang |

Redirects

Handle dynamic redirects managed through Sanity CMS:

// middleware.ts
import { NextRequest } from "next/server";
import { redirectIfNeeded } from "@/lib/sanity";

export async function middleware(request: NextRequest) {
  return await redirectIfNeeded({ request });
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Sitemap Generation

Generate XML sitemaps from your Sanity content. The sitemap utilities automatically find all documents that have a pathname.current field and are marked as indexable (seo.indexable).

Single Language

// app/sitemap.ts
import { generateSitemap } from "@/lib/sanity";

export default function Sitemap() {
  return generateSitemap();
}

Multi-language

When you initialize with i18n config, the generateSitemap function automatically handles multiple languages and alternates:

// app/sitemap.ts
import { generateSitemap } from "@/lib/sanity";

export default function Sitemap() {
  return generateSitemap();
}

Server Actions

disableDraftMode

A pre-built server action for disabling Sanity's draft mode:

// app/layout.tsx
import { disableDraftMode } from "@tinloof/sanity-next/actions/disable-draft-mode";
import { ExitPreview } from "@tinloof/sanity-next/components/exit-preview";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <ExitPreview disableDraftMode={disableDraftMode} />
      </body>
    </html>
  );
}

Advanced Configuration

Custom Client Configuration

import { initSanity } from "@tinloof/sanity-next";

export const sanity = initSanity({
  client: {
    projectId: "custom-project",
    dataset: "development",
    apiVersion: "2024-01-01",
    useCdn: false,
    perspective: "previewDrafts",
    token: process.env.SANITY_WRITE_TOKEN,
  },
  live: {
    browserToken: process.env.NEXT_PUBLIC_SANITY_BROWSER_TOKEN,
    serverToken: process.env.SANITY_SERVER_TOKEN,
  },
});

Custom Base URL Detection

import { initSanity } from "@tinloof/sanity-next";

export const sanity = initSanity({
  baseUrl:
    process.env.NODE_ENV === "development"
      ? "http://localhost:3000"
      : "https://production-site.com",
});

Using a Custom Viewer Token

import { initSanity } from "@tinloof/sanity-next";

export const sanity = initSanity({
  viewerToken: process.env.MY_CUSTOM_VIEWER_TOKEN,
});

Types

PageProps

A helper type for Next.js page components with typed params and search params:

import type { PageProps } from "@tinloof/sanity-next";

export default async function Page({
  params,
  searchParams,
}: PageProps<"slug" | "locale", "page" | "sort">) {
  const { slug, locale } = await params;
  const { page, sort } = await searchParams;

  // slug: string, locale: string
  // page: string | string[] | undefined
  // sort: string | string[] | undefined
}

For catch-all routes:

import type { PageProps } from "@tinloof/sanity-next";

export default async function Page({
  params,
}: PageProps<"...path">) {
  const { path } = await params;
  // path: string[]
}

Requirements

  • Next.js: ^15.0.0 || ^16.0.0
  • React: ^18 || ^19.0.0
  • next-sanity: ^10.0.0 || ^11.0.0

License

MIT © Tinloof

Develop & test

This package uses @sanity/plugin-kit with default configuration for build & watch scripts.

See Testing a plugin in Sanity Studio on how to run this plugin with hotreload in the studio.