A seasonal product launch usually starts with a simple request: “Show the same item in summer’s brand colors.” Then the asks pile up. Add matching swatches. Update the hero image. Adjust the product card. Ensure the campaign page uses the exact same color story everywhere.
For small teams, this is a nightmare of exported files and complicated design handoffs. Each new color means more manual work and more room for versioning mistakes between code and content.
In this guide, you’ll build a source of truth architecture so images update dynamically from a master asset.
By combining these three tools, you’ll create a product preview flow that’s automated and scalable:
-
Sanity (The Brain). As the control layer, it stores the launch name, active brand color, and swatch hex codes.
-
Cloudinary (The Image Engine). Takes one base product image and applies a color transformation on the fly. No more exporting 10 different JPEGs.
-
TanStack Start (The Storefront). Fetches the live theme and renders the updated preview with server-side speed.
Begin with a fresh TanStack Start project. If you’re wondering why, it’s because Start handles the server-side data fetch from Sanity seamlessly, ensuring the theme feels instantaneous to the user without layout shift.
Create your project following the TanStack Start docs, then install the core engine for this build:
npm install @sanity/client @sanity/image-url @cloudinary/url-gen lucide-react
Code language: CSS (css)
Use shadcn/ui for the interface elements to keep the UI clean while we focus on the data logic. If you want the exact look of the demo, follow the shadcn/ui installation guide.
Cloudinary gives this demo its chameleon capabilities. Instead of exporting a new product image for every launch color, you’ll upload one base asset Cloudinary will generate the variation when the page loads.
Steps to set up:
- Create a (free!) Cloudinary account.
- Open the Media Library and upload the product image you want to use as your source.
- Tip: A “clean” product image (white or neutral background) works best, as the recolor effect depends on having a solid source image to transform.
- After uploading, keep these two values from your Cloudinary dashboard:
- Cloud name: You’ll use this to build your delivery URLs.
- Public ID: This tells Cloudinary exactly which image to transform.
The cloud name identifies your account in the delivery URL, and the public ID acts as the address for your asset. Later, we’ll use both with Cloudinary image transformations so the app can generate color-swapped previews from that single asset on the fly.
You’ll use Sanity to define which launch theme is active, store the specific brand hex codes, and link the Cloudinary asset that will be transformed.
Set up a clean, TypeScript-based Studio. Sanity Studio v4 requires Node 20+. I’d recommend separating your studio folder to keep the project organized.
From your project root, run:
mkdir studio
cd studio
npm create sanity@latest
Code language: CSS (css)
When prompted, follow these steps to keep the setup lean:
- Project: Select your existing Sanity project.
- Dataset: Choose
production. - Schema: Choose Clean project with no predefined schema types.
- Language/Package Manager: Use TypeScript and npm.
To bridge Sanity and Cloudinary, you’ll use the official plugin. This allows you to browse and select your Cloudinary assets directly from the Sanity interface.
Install sanity-plugin-cloudinary:
npm install sanity-plugin-cloudinary
Next, register the plugin in your sanity.config.ts:
// studio/sanity.config.ts
import { cloudinarySchemaPlugin } from "sanity-plugin-cloudinary";
export default defineConfig({
// ... other config
plugins: [structureTool(), visionTool(), cloudinarySchemaPlugin()],
});
Code language: JavaScript (javascript)
Let’s take a look at the document type, launchTheme, you created earlier. The secret sauce here is how you’ll store the hex code and the asset reference. Thanks to a regex validation for the hex code, the data going to Cloudinary will always be formatted correctly.
In your schema file, studio/schemaTypes/documents/launchTheme.ts, define these key fields:
defineField({
name: "brandPrimaryColor",
title: "Brand Primary Color",
type: "string",
description: "The hex code for the launch (e.g., #DD7A2E)",
validation: (Rule) =>
Rule.required().regex(/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/),
}),
defineField({
name: "productImage",
title: "Product Image",
type: "cloudinary.asset", // This uses the Cloudinary plugin we installed
});
Code language: CSS (css)
Once your Studio is running (npm run dev), create a new Launch Theme document. This is where you’ll set the vibe for the storefront:
- Theme Title: Autumn Orange
- Brand Primary Color:
#DD7A2E - Product Image: Click the Cloudinary selector, enter your API credentials, and select the base asset you uploaded earlier.
With Sanity and Cloudinary in place, you’ll need to give our app a clean, predictable shape for the data it will consume. This is where you’ll define the theme object, validate the incoming colors, and establish a safe fallback so the UI stays stable even if a content editor makes a mistake.
You’ll use a shared BrandTheme interface to keep the storefront logic simple. This ensures that whether the data is coming from a live Sanity fetch or a local mock, the components receive the exact same object.
In src/types/theme.ts, we define the contract:
/**
* A color value stored as a hex string.
* @example "#DD7A2E"
*/
export type HexColor = `#${string}`
/**
* Brand theme data used by the demo app.
*/
export interface BrandTheme {
id: string
name: string
primaryColor: HexColor
productName: string
baseImageUrl: string
cloudinaryPublicId: string
cloudinarySecureUrl?: string
}
Code language: PHP (php)
Since content is managed externally, it’s best to check it using a small utility in src/lib/theme/brand.ts to normalize the hex color before it reaches the UI or the Cloudinary URL generator.
/**
* Checks if a string looks like a valid hex color.
*/
export function isHexColor(value: string): value is HexColor {
return /^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(value)
}
/**
* Returns a safe fallback hex color if the input is invalid.
*/
export function normalizeHexColor(
value: string,
fallback: HexColor = '#DD7A2E',
): HexColor {
return isHexColor(value) ? value : fallback
}
Code language: JavaScript (javascript)
To speed up the UI build before the Sanity query was fully wired, keep a local mock theme. This acts as both a development tool and a production fallback.
// src/lib/theme/brand.ts
export function getMockBrandTheme(): BrandTheme {
return {
id: "autumn-orange",
name: "Autumn Orange",
primaryColor: "#DD7A2E",
productName: "Nimbus Bottle",
baseImageUrl: "/images/product-bottle.png",
cloudinaryPublicId: "product-bottle_zdwgem",
cloudinarySecureUrl:
"https://res.cloudinary.com/demo-article-projects/image/upload/v1773466265/product-bottle_zdwgem.png",
};
}
Code language: JavaScript (javascript)
This modeling layer is small but critical. By establishing this process early, the Sanity fetch layer knows exactly what to map to, and the Cloudinary logic can rely on a guaranteed hex value.
With your data model ready, you’ll need to bridge the gap between Sanity and our app. The goal is simple: Fetch the currently active launchTheme document and map it into your BrandTheme shape.
In src/lib/sanity/queries.ts, you wrote a GROQ query that targets the specific seasonal product launch. Use the isActive flag to filter and sort by the latest update, ensuring the storefront always reflects the most recent marketing decision.
import groq from "groq";
export const activeLaunchThemeQuery = groq`
*[_type == "launchTheme" && isActive == true] | order(_updatedAt desc)[0]{
_id,
title,
slug,
brandPrimaryColor,
productName,
productImage
}
`;
Code language: JavaScript (javascript)
The data coming back from Sanity’s API is raw, so you’ll need to clean it up before it hits your components. In src/server/services/theme.server.ts, you’ll handle the transformation, including a critical fallback logic. If Sanity returns nothing (e.g., if no theme is marked “active”), the app falls back to our local mock theme.
function mapSanityThemeToBrandTheme(
document: SanityLaunchThemeResult
): BrandTheme {
return {
id: document.slug?.current || document._id,
name: document.title,
primaryColor: normalizeHexColor(document.brandPrimaryColor),
productName: document.productName,
baseImageUrl: "/images/product-bottle.png", // Local fallback asset
cloudinaryPublicId: document.productImage?.public_id || "",
cloudinarySecureUrl: document.productImage?.secure_url,
};
}
Code language: JavaScript (javascript)
To keep the UI fast and SEO-friendly, you’ll expose this logic through a TanStack Start server function in src/server/functions/theme.functions.ts. This allows the homepage loader to fetch the theme on the server and pass it directly into the UI components.
By the end of this step, the app no longer relies on hardcoded strings. It reads the brand’s vision from Sanity and prepares the Cloudinary identifiers for the next (and most visual) step.
With the active theme flowing from Sanity into your TanStack Start app, you’ve reached the phase where you’ll turn a single static image into a dynamic, brand-aware asset.
To create a smooth user experience, you’ll generate two versions of the image: a Base URL (the original) and a Recolor URL (the themed version).
The secret to a professional look and feel is keeping the “framing rules” identical. By using the same crop, width, and padding for both, the product stays still while the color swaps — no jumping or shifting.
In src/lib/cloudinary/url.ts, start by building a clean, normalized version of the original product. You’ll use Cloudinary’s transformation URL format to ensure the original is already centered and padded, matching the layout of your future recolored versions.
export function buildCloudinaryImageUrl(
options: BuildCloudinaryImageUrlOptions
): string {
const cloudName = getPublicEnv("VITE_CLOUDINARY_CLOUD_NAME");
if (!cloudName) return "";
const { publicId, width = 1200, height = 1200 } = options;
return [
`https://res.cloudinary.com/${cloudName}/image/upload`,
`f_auto,q_auto,c_pad,w_${width},h_${height},b_white`, // Normalized frame
publicId,
].join("/");
}
Code language: JavaScript (javascript)
The buildRecolorImageUrl function follows the same pattern but introduces the e_gen_recolor transformation. This tells Cloudinary: “Find the [item] in this image and change its color to [hex code].”
export function buildRecolorImageUrl(
options: BuildRecolorImageUrlOptions
): string {
const cloudName = getPublicEnv("VITE_CLOUDINARY_CLOUD_NAME");
if (!cloudName || !isCloudinaryPreviewEnabled()) return "";
const {
publicId,
color,
prompt = "bottle",
width = 1200,
height = 1200,
} = options;
return [
`https://res.cloudinary.com/${cloudName}/image/upload`,
`f_auto,q_auto,c_pad,w_${width},h_${height},b_white`,
`e_gen_recolor:prompt_${encodeURIComponent(
prompt
)};to-color_${encodeURIComponent(color)}`,
publicId,
].join("/");
}
Code language: JavaScript (javascript)
Since AI-driven recoloring is powerful, add a small toggle in your environment variables. This lets you turn the recolor layer on or off during development without touching the core logic.
VITE_CLOUDINARY_CLOUD_NAME=your_cloud_name
VITE_ENABLE_CLOUDINARY_PREVIEW=true
Code language: JavaScript (javascript)
At this stage, the “engine” is fully built. The app can now take any hex code from Sanity, pass it through this URL builder, and receive a high-fidelity, brand-accurate product preview in milliseconds.
For the full implementation of the URL builders, check out:
src/lib/cloudinary/url.ts
Now that your app can generate both the original and recolored images, you’ll need to give that logic a home. The UI is where the setup finally pays off, turning abstract data into a tangible, interactive experience.
You kept the interface focused on a single task: showing the base product first, then allowing the user to switch between brand swatches to see the image update in real time. By avoiding a heavy gallery or complex product logic, the performance stays snappy.
Use a simple signal (or state) to track which color the user has selected. By default, this is the primaryColor you fetched from Sanity.
const [selectedColor, setSelectedColor] = createSignal<HexColor>(
theme.primaryColor,
)
Code language: HTML, XML (xml)
The process happens in a memoized value. Whenever the selectedColor changes, the component automatically rebuilds the Cloudinary URL. If the Cloudinary ID is missing for any reason, it safely falls back to your base product image.
const previewImageUrl = createMemo(() => {
if (!theme.cloudinaryPublicId) {
return theme.baseImageUrl;
}
return (
buildRecolorImageUrl({
publicId: theme.cloudinaryPublicId,
color: selectedColor(),
prompt: "bottle",
}) || theme.baseImageUrl
);
});
Code language: JavaScript (javascript)
The image block listens to that previewImageUrl. Below it, a row of swatches allows the user to trigger the transformation. Each button is styled dynamically using the hex code provided by the theme data.
// The Image Display
<img
src={previewImageUrl()}
alt={theme.productName}
className="h-full w-full object-contain transition-opacity duration-300"
/>;
// The Swatch Controls
{
swatches.map((color) => (
<button
key={color}
onClick={() => setSelectedColor(color)}
className="h-10 w-10 rounded-full border shadow-sm hover:scale-105 transition-transform"
style={{ backgroundColor: color }}
aria-label={`Select ${color} swatch`}
/>
));
}
Code language: JavaScript (javascript)
Click a swatch, rebuild the Cloudinary URL, and watch the preview update instantly. Because you normalized the image frames in the previous step, the product remains still while the color shifts, creating a high-end, seamless feel for the end-user.
Explore the full UI implementation here:**
With the theme query and preview UI ready, the last step is to connect everything on the homepage. This is where the live Sanity theme, the Cloudinary image engine, and the product preview component finally converge into a working storefront.
The page layout is minimal. Its primary job is to load the active theme on the server using a TanStack Start loader. This ensures that the moment a user hits the page, the brand identity is already baked in, so you don’t have to worry about unstyled content or empty states.
In src/routes/index.tsx, the loader does the heavy lifting:
export const Route = createFileRoute("/")({
loader: async () => {
// Fetches from Sanity or falls back to our local mock
const theme = await getActiveBrandTheme();
return { theme };
},
component: HomePage,
});
Code language: JavaScript (javascript)
Inside the page component, you’ll simply “consume” that loader data. One of the biggest advantages of this architecture is decoupling: The page doesn’t need to know how Sanity queries work or how Cloudinary builds transformation strings. It just hands off the theme object to the ProductPreview component.
function HomePage() {
const { theme } = Route.useLoaderData();
return (
<main className="mx-auto flex min-h-screen max-w-6xl items-center px-6 py-12">
{/* The UI takes it from here */}
<ProductPreview theme={theme} />
</main>
);
}
Code language: JavaScript (javascript)
Once this route is wired, the entire workflow is automated:
- Marketing updates the brand color in Sanity Studio.
- TanStack Start fetches that update on the next page load.
- Cloudinary receives the new hex code and generates the recolored product image on the fly.
This setup removes launch day struggles away from developers and designers, and happens directly in the content management flow instead.
Check out the full route implementation here:
src/routes/index.tsx
We started with a common “Launch Day” headache: one product, several brand colors, and a mountain of manual image exports every time the theme changed. The solution wasn’t to work harder—it was to stop treating every color variation as a separate asset.
By using Sanity as the control layer and Cloudinary as the image engine, we’ve built a storefront that doesn’t just display content—it generates it. TanStack Start acts as the glue, ensuring that this high-tech workflow feels instant and seamless to the end user.
Here’s how the system works once the pipes are connected:
- Set the vibe. You define the active launch theme and brand colors in Sanity.
- Single source. You store one high-quality, neutral base image in Cloudinary.
- Live fetch. The app pulls the latest theme data on every request.
- Dynamic render. Cloudinary generates brand-accurate, recolored previews the moment a user clicks a swatch.
The result is a lighter content workflow, zero duplicate assets, and a storefront that can pivot to a new seasonal campaign in the time it takes to hit Publish in Sanity.
Ready to implement this in your next project? Sign up for a free Cloudinary account today, and check out the full implementation, including all the schemas and transformation logic, in the repository below:
- GitHub Repository: sanity-cloudinary-theme-demo
- Live Demo: Check out the final result