ScreenshotNeo

BlogHow-to

How to Build Dynamic CMS-Driven Galleries with Sanity and Svelte

Build an editor-managed gallery with Sanity, GROQ, and SvelteKit. Learn the content model, image delivery, route loading, pagination, and common fixes.

By the ScreenshotNeo team4 October 202610 min read

A maintainable Sanity and SvelteKit gallery keeps editorial content and image context in Sanity, asks GROQ for only the fields the page needs, and renders the result from a SvelteKit route load function. The pattern works for a small curated grid or a larger collection with query-driven filters and pagination. The schema, route, and query below are a representative starting point; adapt field names and APIs to the versions installed in your project.

Create a document for each gallery item. Give it a title, slug, image, and descriptive alt text. Add category and ordering fields if your interface needs them. Sanity image fields reference asset documents and can also carry per-use context such as crop, hotspot, and captions. That lets editors reuse one source asset while choosing how it appears in different placements. See Sanity’s image type documentation.

A schema can look like this in a modern Sanity Studio project:

import {defineField, defineType} from 'sanity'

export const galleryItem = defineType({
  name: 'galleryItem',
  title: 'Gallery item',
  type: 'document',
  fields: [
    defineField({name: 'title', title: 'Title', type: 'string', validation: rule => rule.required()}),
    defineField({name: 'slug', title: 'Slug', type: 'slug', options: {source: 'title'}, validation: rule => rule.required()}),
    defineField({name: 'alt', title: 'Alternative text', type: 'string', validation: rule => rule.required()}),
    defineField({name: 'caption', title: 'Caption', type: 'text'}),
    defineField({name: 'category', title: 'Category', type: 'string'}),
    defineField({name: 'orderRank', title: 'Sort order', type: 'number'}),
    defineField({name: 'image', title: 'Image', type: 'image', options: {hotspot: true}, validation: rule => rule.required()})
  ]
})

Register the document schema in your Studio configuration. The exact schema registration location depends on your Sanity project setup. Keep editorially meaningful values in the document; avoid duplicating asset metadata that Sanity already provides unless the editor needs to override it.

2. Query a page-shaped collection with GROQ

GROQ filters documents, follows references, orders results, and projects a response shaped for the page. Sanity describes GROQ as a query language for specifying exactly what information an application needs in its GROQ introduction. A gallery query might be:

export const galleryQuery = `*[_type == "galleryItem" && defined(slug.current)] | order(orderRank asc, _createdAt desc) {
  _id,
  title,
  "slug": slug.current,
  alt,
  caption,
  category,
  image {
    crop,
    hotspot,
    asset->{
      _id,
      url,
      metadata {dimensions}
    }
  }
}`

This is an illustrative query, not a query verified against a supplied dataset. Ensure orderRank exists or remove it, and choose a stable ordering that matches the editorial model. Published-content filtering depends on how your dataset and preview workflow are configured. If the page only needs an image URL, dimensions, and crop context, do not return the whole asset document. Sanity’s docs cover dereferencing in GROQ and query projections.

For images inside Portable Text instead of a direct document image field, project the image block’s asset reference and the specific metadata the renderer needs. Keep the returned shape explicit so a schema change is easier to spot.

3. Configure a Sanity client for SvelteKit

Install the Sanity client package in the SvelteKit application if it is not already present, then configure the project ID and dataset from environment variables. Public published-content reads can often use a public dataset without a token; private datasets or preview reads need server-side credentials. Never expose a read token through a public environment variable.

import {createClient} from '@sanity/client'
import {SANITY_PROJECT_ID, SANITY_DATASET, SANITY_READ_TOKEN} from '$env/static/private'

export const sanity = createClient({
  projectId: SANITY_PROJECT_ID,
  dataset: SANITY_DATASET,
  apiVersion: '2025-02-19',
  useCdn: true,
  ...(SANITY_READ_TOKEN ? {token: SANITY_READ_TOKEN} : {})
})

Choose an API version date intentionally and keep it stable for a deployed application. Confirm the import paths and environment variable conventions against the installed SvelteKit and Sanity client versions. For preview or draft content, use the appropriate server-side configuration and ensure drafts cannot leak into public requests.

4. Load the collection from a SvelteKit route

Put the load function in the gallery route, such as src/routes/gallery/+page.server.js. A server load is a useful default when credentials are involved or when the collection should be present in the initial rendered page. SvelteKit load functions return data for the page; consult the official load documentation and migration guidance for your installed version.

import {error} from '@sveltejs/kit'
import {sanity} from '$lib/server/sanity'
import {galleryQuery} from '$lib/server/queries'

export async function load() {
  try {
    const items = await sanity.fetch(galleryQuery)
    return {items}
  } catch (cause) {
    console.error('Gallery query failed', cause)
    throw error(502, 'The gallery could not be loaded right now.')
  }
}

For a public, token-free read, a universal load function may also be suitable. Choose based on whether credentials are needed, whether the content should be rendered on the server, and how you want refresh and preview behavior to work. Return serializable data, and avoid returning client secrets or unnecessary asset fields.

A page component can render the data with native links and responsive CSS. This example uses the asset URL directly for clarity; the next section shows how to request display-sized transformed variants.

<script>
  let {data} = $props()
</script>

<main>
  <h1>Gallery</h1>
  {#if data.items.length === 0}
    <p>No gallery items have been published yet.</p>
  {:else}
    <ul class="gallery">
      {#each data.items as item (item._id)}
        <li>
          <a href={`/gallery/${item.slug}`}>
            <img src={item.image.asset.url} alt={item.alt} loading="lazy" />
            <h2>{item.title}</h2>
            {#if item.caption}<p>{item.caption}</p>{/if}
          </a>
        </li>
      {/each}
    </ul>
  {/if}
</main>

<style>
  .gallery {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
    gap: 1rem;
    list-style: none;
    padding: 0;
  }
  .gallery img { display: block; width: 100%; height: auto; }
  a:focus-visible { outline: 3px solid currentColor; outline-offset: 3px; }
</style>

This component syntax uses Svelte’s current runes-style props. If the project uses an earlier Svelte version, use the syntax supported by that version. Provide meaningful alt text for images that convey information; use empty alt text for purely decorative images. If a gallery item is not a detail page, link to a useful destination or render it as non-interactive content. If you add a lightbox, provide keyboard controls, a visible close control, and focus management.

6. Deliver appropriately sized images

Using original image URLs in every card can transfer more data than the displayed layout needs. Sanity’s image pipeline supports resizing, cropping, and format conversion. Generate a transformed URL for the rendered size, preserve the editor’s crop and hotspot intent where applicable, and provide width and height to reduce layout shifts.

function imageUrl(assetUrl, {width, height, quality = 80} = {}) {
  const url = new URL(assetUrl)
  url.searchParams.set('w', String(width))
  if (height) url.searchParams.set('h', String(height))
  url.searchParams.set('fit', 'crop')
  url.searchParams.set('auto', 'format')
  url.searchParams.set('q', String(quality))
  return url.toString()
}

For production use, prefer Sanity’s image URL builder when the application needs crop and hotspot-aware transformations rather than assembling URLs manually. Configure the builder with the project and dataset, then request a width appropriate to the card. A responsive srcset can let the browser choose among variants:

<img
  src={imageUrl(item.image.asset.url, {width: 640})}
  srcset={`${imageUrl(item.image.asset.url, {width: 320})} 320w, ${imageUrl(item.image.asset.url, {width: 640})} 640w, ${imageUrl(item.image.asset.url, {width: 1000})} 1000w`}
  sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
  width={item.image.asset.metadata.dimensions.width}
  height={item.image.asset.metadata.dimensions.height}
  alt={item.alt}
  loading="lazy"
/>

When using a crop transformation, check the visual result against the editor-selected crop and hotspot. For a detail view, a larger variant may be appropriate than a grid thumbnail. Sanity also documents its image URL parameters and CDN behavior.

7. Add filters and pagination when the collection needs them

There is no universal collection size at which pagination becomes necessary. Decide based on response size, page needs, and whether users should be able to share a category or page in the URL. A small curated set can be fetched as one collection; larger collections can filter and paginate in GROQ.

For example, accept a category and page number as route parameters, validate them, and pass them into a parameterized query. GROQ parameters keep values separate from query text:

const pageQuery = `*[_type == "galleryItem" && defined(slug.current)
  && (!defined($category) || category == $category)]
  | order(orderRank asc, _createdAt desc)[$start...$end] {
    _id, title, "slug": slug.current, alt, caption, category,
    image {crop, hotspot, asset->{_id, url, metadata {dimensions}}}
  }`

const pageSize = 24
const page = Math.max(1, Number(searchParams.get('page')) || 1)
const category = searchParams.get('category') || null
const items = await sanity.fetch(pageQuery, {
  category,
  start: (page - 1) * pageSize,
  end: page * pageSize
})

Adapt optional-filter syntax to the schema and GROQ version in use, and test boundary pages. Add a separate count query if the interface needs a total page count. For stable pagination while content changes, use a cursor or another strategy appropriate to your ordering; offset pagination can shift when new records are inserted before the current range. Keep filter state in the URL when shareability, browser history, or direct linking matters.

8. Troubleshooting

Symptom Likely cause Fix
No items appear The query type, slug constraint, publication access, or dataset does not match the documents. Run the query against the intended project and dataset; confirm the document type and field paths. Check whether the selected dataset exposes published content to this request.
Image URL is null The image has no asset reference, or the projection path does not match the schema. Require an image in Studio, inspect the stored document shape, and verify the asset-> dereference.
Image loads but crop looks wrong The delivery URL ignores crop/hotspot context or requests a transform that conflicts with the intended framing. Use the image builder with the document’s crop and hotspot data; compare card and detail transforms.
403 or unauthorized response A private dataset or draft query lacks a token, or a token was configured for the wrong project. Check project, dataset, and server-only environment values. Keep secrets in server code and use the intended access configuration.
Gallery route returns an error The Sanity request failed, an environment variable is missing, or the API version/configuration is invalid. Check server logs and deployed environment settings. Return a useful page error and retry transient failures according to the application’s policy.
Duplicate or unstable ordering Sort values are missing or multiple documents share a sort key. Add a deterministic tie-breaker such as creation date or document ID and define the editorial ordering rules.
Large images or slow gallery transfer The page requests originals, too many records, or too many image variants. Project only needed fields, request display-sized variants, lazy-load below-the-fold images, and paginate or filter where appropriate.
Type or syntax errors in Svelte The example’s Svelte or SvelteKit syntax differs from the installed version. Check the installed versions and use their matching component props, environment modules, and load-function conventions.

9. Performance, reliability, and cost considerations

  • Keep query responses lean. Every field returned adds work and payload. Project only the information the page renders, and avoid full asset documents when a URL and dimensions suffice.
  • Transform at display size. Deliver thumbnail variants for cards and larger variants for detail pages. Responsive sources prevent sending one oversized file to every screen.
  • Use caching deliberately. Sanity’s CDN can serve content reads; select client CDN settings based on freshness and preview requirements. Editor updates may not be reflected immediately in a cached response.
  • Plan for failures. Handle fetch errors and empty results separately, log server-side context without exposing secrets, and decide whether transient failures merit retrying. Avoid unbounded retries that amplify a service incident.
  • Choose pagination from evidence about your content. The sources establish GROQ filtering and ordering, not a universal page-size or performance threshold. Measure the actual response and user experience before choosing a cutoff.
  • Account for service usage. Sanity is the hosted content source in this architecture. Review the current Sanity plan and usage terms for your dataset and delivery needs; this article does not claim a particular price or quota.

Or skip the browser setup

If you need screenshots of gallery pages for documentation, previews, or visual checks, ScreenshotNeo is a website screenshot API and MCP server. Its single GET endpoint returns PNG, JPEG, WebP, or PDF output, with options for full-page capture, viewport and device presets, custom CSS and JavaScript, selector waits, and more. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);

Cookie banners, popups, and chat widgets are removed before capture. Bot checks, blank pages, and failed loads are never billed, and response headers report the page verdict and billing status. An MCP server lets AI agents use screenshot, page-info, and PDF tools. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. Sign up for 1,000 free screenshots a month, with no card.

FAQ

Keep editor-managed titles, image references, captions, and ordering in Sanity. Keep presentation and interaction behavior in Svelte components.

Yes. An image field refers to an asset and can carry context such as crop and hotspot for that use.

Do I need pagination from the start?

No. Choose based on collection size and user needs; there is no universal threshold. GROQ can filter and order results when the collection requires it.

Where should a Sanity token live?

In server-only configuration when a token is needed. Do not send it to browser code.