@tinloof/sanity-web
v2.0.1
Published
Sanity-related utilities for web development
Readme
@tinloof/sanity-web
A collection of Sanity-related utilities for web development.
Table of Contents
Installation
npm install @tinloof/sanity-webComponents
RichText
A type-safe React component for rendering Sanity Portable Text with automatic TypeScript inference from your Sanity schema. This component builds on @portabletext/react with additional features like automatic heading slug generation and enhanced type safety.
Features
- Full Type Safety: Automatically infers types from your Sanity typegen
- Automatic Heading Slugs: Generates
idattributes for h1-h6 elements based on their text content - Custom Components: Support for blocks, marks, lists, and custom types
- Component Enhancement: User-provided heading components are automatically enhanced with slug IDs
- Zero Runtime Overhead: Type inference happens at compile time
Basic Usage
import { RichText } from "@tinloof/sanity-web/components/rich-text";
import type { BLOG_POST_QUERYResult } from "@/sanity/types";
type PTBody = NonNullable<BLOG_POST_QUERYResult>["ptBody"];
function BlogPost({ data }: { data: BLOG_POST_QUERYResult }) {
return (
<article>
<h1>{data.title}</h1>
<RichText<PTBody> value={data.ptBody} />
</article>
);
}Custom Component Styling
import { RichText } from "@tinloof/sanity-web/components/rich-text";
import type { BLOG_POST_QUERYResult } from "@/sanity/types";
type PTBody = NonNullable<BLOG_POST_QUERYResult>["ptBody"];
function BlogPost({ data }: { data: BLOG_POST_QUERYResult }) {
return (
<RichText<PTBody>
value={data.ptBody}
components={{
block: {
h2: ({ children }) => (
<h2 className="mb-4 mt-8 text-2xl font-bold">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-4 mt-6 text-xl font-semibold">{children}</h3>
),
normal: ({ children }) => (
<p className="leading-relaxed">{children}</p>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 pl-4 italic">
{children}
</blockquote>
),
},
list: {
bullet: ({ children }) => (
<ul className="list-disc space-y-2 pl-6">{children}</ul>
),
number: ({ children }) => (
<ol className="list-decimal space-y-2 pl-6">{children}</ol>
),
},
marks: {
link: ({ children, value }) => (
<a href={value?.url} className="underline">
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold">{children}</strong>
),
em: ({ children }) => <span className="italic">{children}</span>,
code: ({ children }) => (
<code className="bg-gray-100 px-1 py-0.5 font-mono">
{children}
</code>
),
},
}}
/>
);
}Custom Block Types
Handle custom portable text blocks with full type safety:
import { RichText } from "@tinloof/sanity-web/components/rich-text";
import { ExtractPtBlock, ExtractPtBlockType } from "@tinloof/sanity-web/utils";
import type { BLOG_POST_QUERYResult } from "@/sanity/types";
type PTBody = NonNullable<BLOG_POST_QUERYResult>["ptBody"];
// Extract and type a specific block type
type ImageBlock = ExtractPtBlock<PTBody, "imagePtBlock">;
function ImageComponent(props: ImageBlock) {
return (
<figure>
<img src={props.asset?._ref} alt={props.alt || ""} />
{props.caption && <figcaption>{props.caption}</figcaption>}
</figure>
);
}
function BlogPost({ data }: { data: BLOG_POST_QUERYResult }) {
return (
<RichText<PTBody>
value={data.ptBody}
components={{
types: {
imagePtBlock: ImageComponent,
code: ({ value }) => (
<pre>
<code>{value.code}</code>
</pre>
),
table: ({ value }) => <table>{/* Render table from value */}</table>,
},
}}
/>
);
}Props
| Prop | Type | Description |
| ------------ | -------------------------------- | ------------------------------------------ |
| value | T \| null (optional) | The portable text array from Sanity |
| components | TypedPortableTextComponents<T> | Custom component overrides for rendering |
| ...props | PortableTextProps | All other props from @portabletext/react |
TypedPortableTextComponents
The components prop is fully typed based on your Sanity schema:
type TypedPortableTextComponents<T> = {
// Block styles (e.g., h1, h2, normal, blockquote)
block?: {
[K in ExtractedBlockStyles]?: PortableTextBlockComponent;
};
// List types (e.g., bullet, number)
list?: {
[K in ExtractedListTypes]?: PortableTextListComponent;
};
// List item types
listItem?: {
[K in ExtractedListTypes]?: PortableTextListItemComponent;
};
// Marks/annotations (e.g., link, strong, em)
marks?: {
[K in ExtractedMarkTypes]?: PortableTextMarkComponent;
};
// Custom block types (e.g., image, code, table)
types?: {
[K in ExtractedCustomTypes]?: PortableTextTypeComponent;
};
// Fallback handlers
unknownType?: PortableTextComponents["unknownType"];
unknownMark?: PortableTextComponents["unknownMark"];
unknownList?: PortableTextComponents["unknownList"];
unknownBlockStyle?: PortableTextComponents["unknownBlockStyle"];
hardBreak?: PortableTextComponents["hardBreak"];
};Automatic Heading Slug Generation
All heading components (h1-h6) automatically get an id attribute based on their text content. This enables:
- Anchor links to specific sections
- Table of contents generation
- Deep linking to headings
// Input portable text
{
_type: "block",
style: "h2",
children: [{ text: "Getting Started with Sanity" }]
}
// Rendered output
<h2 id="getting-started-with-sanity">Getting Started with Sanity</h2>Custom heading components are also enhanced with slugs:
<RichText
value={ptBody}
components={{
block: {
h2: ({ children }) => (
<h2 className="custom-heading">{children}</h2>
),
},
}}
/>
// Renders as:
<h2 id="your-heading-text" className="custom-heading">Your Heading Text</h2>Portable Text Type Utilities
The package also exports utility types for working with portable text:
import type {
ExtractPtBlockType,
ExtractPtBlock,
} from "@tinloof/sanity-web/utils/portable-text";
type PTBody = NonNullable<BLOG_POST_QUERYResult>["ptBody"];
// Get all custom block type names (excludes "block")
type CustomTypes = ExtractPtBlockType<PTBody>;
// Result: "imagePtBlock" | "code" | "table"
// Get the shape of a specific block type
type ImageBlock = ExtractPtBlock<PTBody, "imagePtBlock">;
// Result: Full type definition of the image blockComplete Example
See the blog-next example for a production-ready implementation with:
- Custom styling for all block types
- List rendering with nested list support
- Mark components for links, code, emphasis
- Custom block components for images, code blocks, and tables
ExitPreview
A React component that provides a UI for exiting Sanity's draft mode/preview mode. The component renders a fixed-position button that allows users to disable draft mode and refresh the page.
Usage
import ExitPreviewClient from "./components/exit-preview-client";
import { disableDraftMode } from "./actions";
// In your app/layout.tsx or similar
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<ExitPreviewClient disableDraftMode={disableDraftMode} />
</body>
</html>
);
}Create a client component wrapper in app/components/exit-preview-client.tsx:
"use client";
import { ExitPreview, ExitPreviewProps } from "@tinloof/sanity-web";
export default function ExitPreviewClient(props: ExitPreviewProps) {
return <ExitPreview {...props} />;
}Create the server action in app/actions.ts:
"use server";
import { draftMode } from "next/headers";
export async function disableDraftMode() {
"use server";
await Promise.allSettled([
(await draftMode()).disable(),
// Simulate a delay to show the loading state
new Promise((resolve) => setTimeout(resolve, 1000)),
]);
}Props
| Prop | Type | Description |
| ------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------- |
| disableDraftMode | () => Promise<void> | A function that disables draft mode. This should handle clearing preview cookies and revalidating paths. |
| className | string (optional) | CSS class name to apply to the button. When provided, default styles are not applied. |
| styles | React.CSSProperties (optional) | Additional inline styles to merge with default styles. Only applied when className is not provided. |
Features
- Conditional rendering: Only shows when not in Sanity's Presentation Tool
- Loading state: Shows "Disabling..." text while the draft mode is being disabled
- Auto-refresh: Automatically refreshes the page after disabling draft mode
- Fixed positioning: Positioned at the bottom center of the screen with high z-index
- Accessible: Properly disabled during loading state
Styling
The component provides flexible styling options:
Default styling: When no className is provided, the component uses inline styles for a black button with white text, positioned fixed at the bottom center of the screen.
Custom styles with styles prop: You can merge additional styles with the defaults:
<ExitPreview
disableDraftMode={disableDraftMode}
styles={{ backgroundColor: "blue", borderRadius: "8px" }}
/>Custom styling with className prop: For complete control, provide a className. This disables all default styles:
<ExitPreview
disableDraftMode={disableDraftMode}
className="fixed bottom-4 left-1/2 -translate-x-1/2 bg-blue-500 text-white rounded-md py-2 px-4"
/>Dependencies
- Requires
next-sanity/hooksforuseIsPresentationTool - Requires
next/navigationforuseRouter - Built for Next.js App Router with React 18+ (uses
useTransition)
SectionsRenderer
A React component that dynamically renders sections based on their _type field. This component is designed to work with Sanity's modular content approach, where pages contain arrays of section objects that need to be rendered with different components.
Usage
import { SectionsRenderer } from "@tinloof/sanity-web";
import HeroSection from "./sections/hero-section";
import CallToAction from "./sections/call-to-action";
// Basic usage
export default function Page({ sections }) {
return (
<SectionsRenderer
data={sections}
components={{
"section.hero": HeroSection,
"section.cta": CallToAction,
}}
/>
);
}Props
| Prop | Type | Description |
| ------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------ |
| data | TSections (optional) | Array of section data objects to render |
| components | SectionComponentMap<TSections, TSharedProps> | Map of section type strings to their React components |
| sharedProps | TSharedProps (optional) | Props shared across all section components |
| className | string (optional) | Optional container class name |
| fallbackComponent | (props: {type: string, availableTypes: string[]}) => ReactNode | Custom fallback component callback for missing section types |
| showDevWarnings | boolean (optional, default: true in development) | Show dev warnings for missing components |
Features
Dynamic Section Rendering: Automatically maps section _type fields to React components, making it easy to build modular page layouts.
Enhanced Component Props: Each section component receives:
- All original section data as props
_sectionIndex: The index of the section in the array_sections: Reference to the complete sections arrayrootHtmlAttributes: Object withdata-sectionattribute and deep-linkid(based on the section's_key)
Deep Link Support: Automatically generates unique IDs for each section using the section's _key, enabling smooth anchor navigation.
Development-Friendly:
- Shows helpful warnings when section components are missing
- Displays a fallback component with available section types
- Validates section data structure
Custom Fallback Components: Provide a callback function that receives the missing section type and available types:
<SectionsRenderer
data={sections}
components={componentMap}
fallbackComponent={({ type, availableTypes }) => (
<div className="p-4 bg-yellow-100 border border-yellow-400 rounded">
<h3>Missing Component: {type}</h3>
<p>Available: {availableTypes.join(", ")}</p>
</div>
)}
/>Section Component Structure: Your section components will receive enhanced props:
type SectionComponentProps = {
// Your section data fields
title?: string;
content?: any;
// Enhanced props from SectionsRenderer
_key: string;
_type: string;
_sectionIndex: number;
_sections: SectionData[];
rootHtmlAttributes: {
"data-section": string;
id: string;
};
};
export default function HeroSection({
title,
content,
_sectionIndex,
}: SectionComponentProps) {
return (
<section>
<h1>{title}</h1>
<div>{content}</div>
</section>
);
}Factory Function
Create reusable, pre-configured, type-safe renderers with createSectionsComponent:
// components/sections/index.ts
import { createSectionsComponent } from "@tinloof/sanity-web/components";
import type { PAGE_QUERYResult } from "@/sanity/types";
import HeroSection from "./hero-section";
import CallToAction from "./call-to-action";
import TextSection from "./text-section";
// Create the sections renderer
const Sections = createSectionsComponent<
NonNullable<NonNullable<PAGE_QUERYResult>["sections"]>
>({
components: {
"section.hero": HeroSection,
"section.cta": CallToAction,
"section.text": TextSection,
},
className: "space-y-16",
showDevWarnings: true,
fallbackComponent: ({ type }) => <div>Custom fallback for: {type}</div>,
});
// Infer SectionProps directly from the Sections component
type SectionProps = (typeof Sections)["_SectionProps"];
export { Sections, type SectionProps };Use throughout your app with minimal props:
// pages/[slug].tsx
import { Sections } from "@/components/sections";
export default function Page({ sections }) {
return <Sections data={sections} />;
}Shared Props
Pass props that are shared across all section components using sharedProps:
// components/sections/index.ts
import { createSectionsComponent } from "@tinloof/sanity-web/components";
import type { PAGE_QUERYResult } from "@/sanity/types";
import HeroSection from "./hero-section";
import CallToAction from "./call-to-action";
// Create renderer with shared props type
const Sections = createSectionsComponent<
NonNullable<NonNullable<PAGE_QUERYResult>["sections"]>,
{ locale: string; isPreview: boolean }
>({
components: {
"section.hero": HeroSection,
"section.cta": CallToAction,
},
});
// Infer SectionProps directly from the Sections component
type SectionProps = (typeof Sections)["_SectionProps"];
export { Sections, type SectionProps };Usage - sharedProps is required when shared props type is defined:
// pages/[slug].tsx
import { Sections } from "@/components/sections";
export default function Page({ sections, locale }) {
return (
<Sections data={sections} sharedProps={{ locale, isPreview: false }} />
);
}Section components receive shared props along with their section data:
// components/sections/hero-section.tsx
import type { SectionProps } from ".";
export default function HeroSection({
title,
locale, // From sharedProps
isPreview, // From sharedProps
}: SectionProps["section.hero"]) {
return (
<section>
<h1>{title}</h1>
<p>Locale: {locale}</p>
</section>
);
}TypeScript Support
The component provides full TypeScript support with automatic type inference from your Sanity schema. The _SectionProps property on the created renderer gives you access to fully typed props for each section:
// components/sections/index.ts
import { createSectionsComponent } from "@tinloof/sanity-web/components";
import type { PAGE_QUERYResult } from "@/sanity/types";
import HeroSection from "./hero-section";
import CallToAction from "./call-to-action";
// Create the sections renderer
const Sections = createSectionsComponent<
NonNullable<NonNullable<PAGE_QUERYResult>["sections"]>
>({
components: {
"section.hero": HeroSection,
"section.cta": CallToAction,
},
});
// Infer SectionProps from the Sections component using _SectionProps
type SectionProps = (typeof Sections)["_SectionProps"];
export { Sections, type SectionProps };Section components import SectionProps and access their specific type using bracket notation:
// components/sections/hero-section.tsx
import type { SectionProps } from ".";
// Props are fully typed based on your Sanity schema
export default function HeroSection({
title,
subtitle,
_sectionIndex,
_key,
rootHtmlAttributes,
}: SectionProps["section.hero"]) {
return (
<section {...rootHtmlAttributes}>
<h1>{title}</h1>
{subtitle && <h2>{subtitle}</h2>}
</section>
);
}The SectionProps type is a mapped type where each key is a section _type (e.g., "section.hero", "section.cta") and the value is the fully typed props for that section, including:
- All fields from your Sanity schema for that section type
_key: Unique key for the section_sectionIndex: Index of the section in the array_sections: The full sections arrayrootHtmlAttributes: Object withdata-sectionandidfor deep linking- Any shared props defined in the second generic parameter
Utils
Metadata Resolution
Utilities for generating Next.js metadata from Sanity CMS content. These functions help create comprehensive metadata including SEO tags, Open Graph images, canonical URLs, and internationalization support.
createSanityMetadataResolver
Creates a configured metadata resolver function that can be reused across multiple pages.
import { createSanityMetadataResolver } from "@tinloof/sanity-web";
import { client } from "./sanity/client";
// Create a configured metadata resolver
export const resolveSanityMetadata = createSanityMetadataResolver({
client,
websiteBaseURL: "https://example.com",
defaultLocaleId: "en",
});Usage in Next.js Pages
Use the metadata resolver in your page or layout components:
import { resolveSanityMetadata } from "@/lib/sanity/metadata";
import { loadHome } from "@/data/sanity";
import { notFound } from "next/navigation";
type IndexRouteProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata(
props: IndexRouteProps,
parentPromise: ResolvingMetadata
) {
const parent = await parentPromise;
const locale = (await props.params).locale;
const initialData = await loadHome({ locale });
if (!initialData) return notFound();
return resolveSanityMetadata({ ...initialData, parent });
}Props for resolveSanityMetadata:
| Prop | Type | Description |
| -------------- | --------------------------- | ------------------------------------------------- |
| parent | ResolvedMetadata | Parent metadata from Next.js |
| title | string (optional) | Page title |
| seo | object (optional) | SEO configuration with title, description, images |
| pathname | string\|object (optional) | Page pathname (string or slug object) |
| locale | string (optional) | Current locale |
| translations | array (optional) | Array of translation objects |
| indexable | boolean (optional) | Whether page should be indexed by search engines |
SEO Object Structure:
type Seo = {
title?: string;
description?: string;
image?: Image; // Sanity image asset
};Redirects
Utilities for handling URL redirects managed through Sanity CMS. These functions help you implement dynamic redirects that can be managed by content editors without code changes.
getRedirect
Fetches redirect configuration from Sanity for a given source path. This function automatically generates path variations to handle different URL formats and queries Sanity to find matching redirect rules.
import { getRedirect } from "@tinloof/sanity-web";
import { sanityFetch } from "@/data/sanity/client";
// In Next.js middleware
export async function middleware(request: NextRequest) {
const redirect = await getRedirect({
source: request.nextUrl.pathname,
sanityFetch,
});
if (redirect) {
return NextResponse.redirect(new URL(redirect.destination, request.url), {
status: redirect.permanent ? 301 : 302,
});
}
}Props:
| Prop | Type | Description |
| ------------- | ------------------------ | ---------------------------------------------- |
| source | string | The source path to look up (e.g., "/old-page") |
| sanityFetch | DefinedSanityFetchType | Sanity fetch function from next-sanity |
| query | string (optional) | Custom GROQ query (defaults to REDIRECT_QUERY) |
Returns: Promise that resolves to redirect data or null if no redirect found.
Redirect Data:
| Property | Type | Description |
| ------------- | --------- | ------------------------------------------------- |
| source | string | The source path that triggers the redirect |
| destination | string | The destination URL to redirect to |
| permanent | boolean | Whether this is a permanent or temporary redirect |
getPathVariations
Generates path variations for flexible redirect matching. This function creates multiple variations of a path to handle different URL formats that users might access.
import { getPathVariations } from "@tinloof/sanity-web";
getPathVariations("/about-us/");
// Returns: ["about-us", "/about-us/", "about-us/", "/about-us"]
getPathVariations("contact");
// Returns: ["contact", "/contact/", "contact/", "/contact"]Props:
| Prop | Type | Description |
| ------ | -------- | ----------------------------------------- |
| path | string | The input path to generate variations for |
Returns: Array of path variations to match against redirect rules.
REDIRECT_QUERY
A GROQ query constant for fetching redirect configuration from Sanity settings.
*[_type == "settings"][0].redirects[@.source in $paths][0]Schema Setup
For the best experience, use the redirectsSchema from @tinloof/sanity-studio which provides a searchable interface and validation for managing redirects. See the redirectsSchema documentation for setup instructions.
import { redirectsSchema } from "@tinloof/sanity-studio";
export default defineType({
type: "document",
name: "settings",
fields: [
redirectsSchema,
// ... other fields
],
});Sitemap
Utilities for generating sitemaps from Sanity content for Next.js applications. These functions help create dynamic sitemaps that include all indexable pages from your Sanity CMS.
generateSanitySitemap
Generates a sitemap for single-language Next.js applications using Sanity content.
import { generateSanitySitemap } from "@tinloof/sanity-web";
import { sanityFetch } from "@/data/sanity/live";
export default function Sitemap() {
return generateSanitySitemap({
sanityFetch,
websiteBaseURL: "https://tinloof.com",
});
}Props:
| Prop | Type | Description |
| ---------------- | ------------------------ | -------------------------------------- |
| websiteBaseURL | string | The base URL of your website |
| sanityFetch | DefinedSanityFetchType | Sanity fetch function from next-sanity |
Returns: Array of sitemap entries with url and lastModified properties.
generateSanityI18nSitemap
Generates a sitemap for multi-language Next.js applications using Sanity content with internationalization support.
import { generateSanityI18nSitemap } from "@tinloof/sanity-web";
import { sanityFetch } from "@/data/sanity/live";
const i18n = {
defaultLocaleId: "en",
locales: [
{ id: "en", title: "English" },
{ id: "fr", title: "Français" },
],
};
export default function Sitemap() {
return generateSanityI18nSitemap({
sanityFetch,
websiteBaseURL: "https://tinloof.com",
i18n,
});
}Props:
| Prop | Type | Description |
| ---------------- | ------------------------ | ----------------------------------------- |
| websiteBaseURL | string | The base URL of your website |
| sanityFetch | DefinedSanityFetchType | Sanity fetch function from next-sanity |
| i18n | i18nConfig | Internationalization configuration object |
Returns: Array of sitemap entries with url, lastModified, and alternates.languages properties for multi-language support.
Requirements:
- Documents must have a boolean field called
indexableset totrueto be included in the sitemap - Documents must have a slug field called
pathname(typicallypathname.current) containing the URL path, or be of type "home" - For i18n sitemaps, documents must have a
localefield - Translation metadata must be properly configured for alternate language URLs
- The sitemap functions should be used in a
sitemap.tsfile in your Next.js app directory
Fragments
TRANSLATIONS_FRAGMENT
A GROQ fragment that fetches translation metadata for a document. This fragment retrieves all translations associated with a document through the translation metadata system.
"translations": *[_type == "translation.metadata" && references(^._id)].translations[].value->{
"pathname": pathname.current,
locale
}Usage examples
Here are some common usage patterns for the TRANSLATIONS_FRAGMENT:
Modular page query:
import { TRANSLATIONS_FRAGMENT } from "@tinloof/sanity-web";
export const MODULAR_PAGE_QUERY = defineQuery(`
*[_type == "modular.page" && pathname.current == $pathname && locale == $locale][0] {
...,
sections[] ${SECTIONS_BODY_FRAGMENT},
${TRANSLATIONS_FRAGMENT},
}`);Sitemap query:
import { TRANSLATIONS_FRAGMENT } from "@tinloof/sanity-web";
export const SITEMAP_QUERY = defineQuery(`
*[((pathname.current != null || _type == "home") && seo.indexable && locale == $defaultLocale)] {
pathname,
"lastModified": _updatedAt,
locale,
_type,
"translations": *[_type == "translation.metadata" && references(^._id)].translations[].value->{
"pathname": pathname.current,
locale
},
}`);License
MIT © Tinloof
Develop & test
This plugin 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.
