Background
This is a walkthrough of how Anaveda website integrates Sanity CMS to deliver travel articles. There were plenty of options for architecture and the knowledge and architecture that came to be from working on this.
Our Use-Case
Anaveda travel journal has SEO blog-style articles. We needed to support rendering rich text content on the client with a polished reading experience: consistent typography, responsive media with credit captions, accessible links, and a footnote system.
Core Implementation
Quick overview:
- Single CMS gateway through CmsContentProvider shared as a singleton
- One Sanity client plus image URL builder for CDN image transforms
- Thin GROQ query wrappers: slugs, article by slug, article list
- Portable Text mapping with custom image figure, caption, and credit link plus footnotes with numbered links
- Cache tags for blog and global with webhook revalidation via secret
- Sanity Studio schema for travelArticle and customImage, plus a WithImages server wrapper for shared assets
CMS provider slice
All CMS interactions funnel through the CmsContentProvider interface so that the rest of the app stays decoupled from Sanity specifics:
export interface CmsContentProvider {
getAllTravelArticleSlugs(): Promise<string[]>;
fetchTravelArticleBySlug(slug: string): Promise<TravelArticle>;
fetchTravelArticleList(): Promise<TravelArticleInfo[]>;
getAllAuthorSlugs(): Promise<string[]>;
fetchAuthorBySlug(slug: string): Promise<Author | null>;
getImageByName(name: string): Promise<CustomImage>;
getImages(names?: string[]): Promise<CustomImage[]>;
// ...other domains omitted
}src/contentProvider/contentProviderSingleton.ts exports one instance of the Sanity-backed provider so every component uses the same entry point.
Reused Sanity client
src/contentProvider/sanity/sanityContentProvider.ts creates the client once and reuses it:
const sanityClient = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
apiVersion: process.env.SANITY_API_VERSION,
useCdn: true,
});
export const sanityImageBuilder = imageUrlBuilder(sanityClient);The sanityClient usage is restricted to this file for running queries. The queries set Next.js cache tags so we can revalidate by content type. sanityImageBuilder is exposed and uses CDN features for image optimization. For example, for author avatars:
sanityImageBuilder.image(author.photo.url)
.width(160).height(160).fit('crop').auto('format').url()Travel Article Queries
Routes rely on the following helpers (direct excerpts from sanityContentProvider.ts). For the static prerendering:
async getAllTravelArticleSlugs(): Promise<string[]> {
const query = `*[_type == "travelArticle"]{ slug }`;
const slugs = await sanityClient.fetch(query, {},
{ cache: 'force-cache', next: { tags: ['global', 'blog'] } }
);
return slugs.map((s: { slug: { current: string } }) => s.slug.current);
}For the article pages:
fetchTravelArticleBySlug(slug: string): Promise<TravelArticle> {
const query = `
*[_type == "travelArticle" && slug.current == $slug][0]{
title,
"slug": slug.current,
date,
author->{
name,
bio,
photo{
"url": asset->url,
alt,
description,
credits
},
"slug": slug.current
},
coverImage{
alt,
description,
credits,
creditsLink,
"url": asset->url,
},
coverImageMobile{
alt,
description,
credits,
creditsLink,
"url": asset->url,
},
content[]{
...,
_type == "customImage" => {
alt,
description,
credits,
creditsLink,
"url": asset->url,
}
}
}
`;
return sanityClient.fetch(
query,
{ slug },
{ cache: 'force-cache', next: { tags: ['global', 'blog'] } }
);
}For listing articles (as cards):
fetchTravelArticleList(): Promise<TravelArticleInfo[]> {
const query = `
*[_type == "travelArticle"]{
title,
summary,
author->{
name
},
cardIcon{
"url": asset->url,
alt,
description,
credits
},
"slug": slug.current
} | order(_createdAt desc)
`;
const travelArticles = sanityClient.fetch(query, {},
{ next: { tags: ['global', 'blog'] }}
);
return travelArticles;
};These queries keep payloads tight and massage Sanity image assets into CustomImage, so the renderer has enough data for captions and attribution links.
export interface CustomImage {
url: string,
alt: string,
description?: string,
credits?: string
creditsLink?: string
}Portable Text Renderer
src/app/travel-journal/[journal-name]/PortableTextClient.tsx defines the mapping from rich text marks/blocks to our UI components, controlling both block-level styles (H1–H6, lists, quotes) and inline behavior (links, emphasis), plus any custom content types. Two key pieces are worth copying into the docs.
Custom image blocks
const portableTextComponents: PortableTextComponents = {
types: {
customImage: ({ value }) => {
const imageUrl =
value?.url ?? value?.asset?.url ?? value?.image?.asset?.url;
if (!imageUrl) return null;
const caption = value?.description;
const credit = value?.credits;
const creditsLink = value?.creditsLink;
return (
<Box component="figure" sx={{ my: RHYTHM, mx: 'auto', textAlign: 'center' }}>
<Image
src={imageUrl}
alt={value?.alt || 'Blog image'}
width={1600}
height={900}
sizes="(max-width: 900px) 100vw, 900px"
/>
{(caption || credit) && (
<Typography variant="caption" component="figcaption" sx={{ mt: RHYTHM }}>
{caption}
{caption && credit ? ' by ' : ''}
{credit && creditsLink ? (
<Link href={creditsLink}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'inherit' }}>
{credit}
</Link>
) : (
credit
)}
</Typography>
)}
</Box>
);
},
},
// …
};The renderer reads description, credits, and creditsLink straight from the CustomImage values we queried earlier.
Footnote marks
marks: {
footnote: ({ value }) => {
const key = value?._key;
if (!key) return null;
const num = order[key] ?? 0;
const isLink = value?.kind === 'link';
const preview = isLink ? (value?.label || value?.href || '') : (value?.note || '');
const targetId = `fn-${key}`;
const refId = `fnref-${key}`;
return (
<span id={refId} style={{ display: 'inline-block', scrollMarginTop: ANCHOR_OFFSET_PX }}>
<sup style={{ lineHeight: 0 }}>
<Tooltip title={preview || ''} arrow>
<a
href={`#${targetId}`}
aria-label={num ? `Footnote ${num}` : 'Footnote'}
style={{ textDecoration: 'none' }}>
[{num || '�?�'}]
</a>
</Tooltip>
</sup>
</span>
);
},
},collectFootnotes runs once to assign numbers. The marks render tooltips and link to the ordered list rendered after the article body.
Rendering Flow
src/app/components/sections/travelJournal/TravelJournalSection.tsx: server component, grabs the full list, shuffles, renders threeTravelJournalCards, and links to/travel-journal.src/app/travel-journal/page.tsx: server component, loads hero assets viaWithImages, sets metadata, renders the full grid, ends withNewsletterSection.src/app/travel-journal/[journal-name]/page.tsx: prebuilds static params, fetches one article, pipes data intoGenericHero,PortableTextClient, and the newsletter CTA.src/app/travel-journal/authors/[name]/page.tsx: builds author pages from slugs, normalises portraits, and points back to the article archive.
Cache Invalidation
Sanity webhooks trigger src/app/api/revalidate/route.ts to clear cache tags:
const ALLOWED_TAGS = ['global', 'blog', 'packages', 'regions'] as const;
export type Allowed = (typeof ALLOWED_TAGS)[number];
const isAllowedTag = (s: string): s is Allowed =>
(ALLOWED_TAGS as readonly string[]).includes(s);
export async function POST(req: NextRequest) {
const secret = req.headers.get('revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
const candidate = req.headers.get('revalidate-tag')?.trim().toLowerCase();
if (!candidate || !isAllowedTag(candidate)) {
return NextResponse.json({ message: 'Invalid tag' }, { status: 400 });
}
const tag: Allowed = candidate;
revalidateTag(tag);
return NextResponse.json({ revalidated: true, tag, now: Date.now() });
}Travel journal pages use the blog tag. To reset all types of content use the global tag.
Sanity Studio Schema
Travel articles in Studio require the following fields (travelArticleType):
export const travelArticleType = defineType({
name: 'travelArticle',
title: 'Travel Article',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string', validation: (rule) => rule.required() }),
defineField({
name: 'slug',
type: 'slug',
options: { source: 'title', maxLength: 96 },
validation: (rule) => rule.required(),
}),
defineField({ name: 'summary', type: 'text', rows: 3 }),
defineField({ name: 'author', type: 'reference',
to: [{ type: 'author' }], validation: (rule) => rule.required() }),
defineField({ name: 'coverImage', type: 'customImage',
validation: (rule) => rule.required() }),
defineField({ name: 'coverImageMobile', type: 'customImage' }),
defineField({ name: 'cardIcon', type: 'customImage',
validation: (rule) => rule.required() }),
defineField({
name: 'content',
type: 'array',
of: [
{
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'H1', value: 'h1' },
{ title: 'H2', value: 'h2' },
{ title: 'H3', value: 'h3' },
{ title: 'Quote', value: 'blockquote' },
],
lists: [
{ title: 'Bullet', value: 'bullet' },
{ title: 'Number', value: 'number' },
],
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Underline', value: 'underline' },
{ title: 'Strike', value: 'strike-through' },
{ title: 'Superscript', value: 'sup', icon: ChevronUpIcon },
{ title: 'Subscript', value: 'sub', icon: ChevronDownIcon },
],
annotations: [
{
name: 'footnote',
title: 'Footnote',
type: 'object',
icon: BookIcon,
fields: [
{
name: 'kind',
title: 'Type',
type: 'string',
options: {
list: [
{ title: 'Text', value: 'text' },
{ title: 'Link', value: 'link' },
],
layout: 'radio',
},
initialValue: 'text',
},
{ name: 'note', title: 'Note (text type)', type: 'text', rows: 3,
hidden: ({ parent }) => parent?.kind !== 'text' },
{ name: 'href', title: 'URL (link type)', type: 'url',
hidden: ({ parent }) => parent?.kind !== 'link' },
{ name: 'label', title: 'Link label', type: 'string',
hidden: ({ parent }) => parent?.kind !== 'link' },
],
},
],
},
},
{ type: 'customImage' },
],
}),
defineField({ name: 'date', type: 'datetime', validation: (rule) => rule.required() }),
],
});Bonus
Reusable media (for heroes and cards) lives in anavedaImage type, and there's a neat WithImages wrapper to keep base components server-side, while supplying images to client components. Here's the implementation:
'use server'
import { CustomImage } from "@app/types/auxiliaryTypes"
import { contentProvider } from "@src/contentProvider/contentProviderSingleton";
import { ReactNode } from "react";
interface Props {
imageNames: string[];
children: (imagesByName: Record<string, CustomImage>) => ReactNode;
}
export default async function WithImages({ children, imageNames }: Props) {
const entries = await Promise.all(
imageNames.map(async (name) => {
try {
const image = await contentProvider.getImageByName(name)
return [name, image]
} catch (err) {
console.log(`${err}. Image ${name} could not be loaded from Sanity`);
}
return [name, null] as const;
})
)
const imagesByName = Object.fromEntries(
entries.filter(([, v]) => v !== null)
) as Record<string, CustomImage>;
return (
<>{children(imagesByName)}</>
)
}The usage is as follows:
<WithImages imageNames={[COVER_DESKTOP, COVER_MOBILE]}>
{(images) => {
return (
<Box component="main" sx={{ backgroundColor: "background.default" }}>
<GenericHero
title="Travel Journal"
subtitle="Stories to Inspire Journeys across India"
backgroundSrc={images[COVER_DESKTOP].url}
backgroundSrcMobile={images[COVER_MOBILE].url}
overlay={0.3}
containerMaxWidth="md"
align="center"
minHeight={'80vh'}
sx={{ mb: { xs: 1, md: 2 } }}
/>
...
)
}}
</WithImages>Thanks for reading. Hopefully this is usefull. Good luck on your project.