← Blog

Guide,

Building a Saleor App for AI-Powered Catalog Enrichment

Build a Saleor Dashboard widget that reads product context and uses an LLM to suggest structured attribute values staff can review and apply.

Most catalog problems start small: missing material values, inconsistent size attributes, supplier descriptions pasted into SEO fields, and product facts buried in free text. Over time those gaps become broken filters, rejected marketplace feeds, manual cleanup, and product data that downstream tools cannot reliably read.

This tutorial walks through a small Saleor App that adds a product details widget to the Saleor Dashboard. The widget reads an existing product description, finds buried information such as publisher, weight, dimensions, or format, and proposes matching structured attribute values. Each suggestion includes a short evidence quote, and nothing is written back until a staff user reviews it and clicks Apply.

The full demo app is open source on GitHub. The focus here is the architecture and code paths that prove the approach, not setup boilerplate.

Saleor Dashboard product detail page with the Product AI Assistant widget showing AI-generated attribute suggestions ready to apply

The product data problem

"AI product description generator" is a familiar entry point. The harder, more useful problem is product data enrichment: structured attributes that drive storefront filters, marketplace feeds, SEO, recommendations, and agentic commerce surfaces where models read your catalog directly.

Common symptoms:

  • A "Material" attribute exists on the product type but is filled on only 40% of products.
  • "Publisher", "Narrator", or "Format" values exist in the description but not in the structured attribute set.
  • Channel-specific feeds (Google, Meta, marketplaces) reject products because of incomplete required attributes.
  • New product types are introduced faster than data teams can backfill the historical catalog.

The hard part is producing constrained, structured values that match Saleor's product model: the right attribute IDs, input types, and existing choice slugs.

Why the Saleor Dashboard is the right place for this

Catalog gaps are usually discovered while merchants edit products, review attributes, or prepare content for a channel. Keeping the workflow inside the Dashboard means:

  • Staff can review suggestions in the same screen where they normally edit the product.
  • The app can use Saleor's existing permission model (MANAGE_PRODUCTS here).
  • We do not need to fork the Dashboard to add a new UI surface.
  • Approved changes are written back through the same Saleor GraphQL API the Dashboard already uses.

A Saleor App gives us all of that without touching the Dashboard codebase.

App shape

Architecture diagram showing the Saleor Dashboard, Saleor App widget page, protected API routes, Saleor API, and AI model

Starting from the Saleor App Template

The Saleor App Template already includes the App SDK, App Bridge wiring, manifest handler, auth-token-persistence layer (APL), GraphQL Codegen, and a dev setup.

BASH
1git clone https://github.com/saleor/saleor-app-template.git product-ai-assistant
2cd product-ai-assistant
3pnpm install

The full setup is documented in the Saleor App Template guide. Everything below is layered on top of it.

Two App Bridge details matter:

  • appBridgeState is hydrated after the iframe handshake completes. Production code should gate rendering on appBridgeState?.ready (or fall back to a timeout so the UI never gets stuck).
  • The productId arrives as a query parameter on the iframe URL. Saleor injects it automatically for widget mount points.

Step 2: Register the widget in the app manifest

The manifest is what tells Saleor what the app contributes. Adding the widget is one extension entry:

TS
1// src/pages/api/manifest.ts (excerpt)
2import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
3
4export default createManifestHandler({
5 async manifestFactory({ appBaseUrl }) {
6 return {
7 name: "Product AI Assistant",
8 id: "product-ai-assistant",
9 version: "1.0.0",
10 tokenTargetUrl: `${appBaseUrl}/api/register`,
11 appUrl: appBaseUrl,
12 permissions: ["MANAGE_PRODUCTS"],
13 extensions: [
14 {
15 label: "Product AI Assistant",
16 mount: "PRODUCT_DETAILS_WIDGETS",
17 target: "WIDGET",
18 url: `${appBaseUrl}/product-widget`,
19 permissions: ["MANAGE_PRODUCTS"],
20 },
21 ],
22 };
23 },
24});

Two details are easy to miss:

  • The app-level permissions array must include every permission used by any per-extension permissions entry. Per-extension permissions can never exceed the app's own scopes.
  • target: "WIDGET" makes Saleor render the extension as an inline panel on the product page instead of a navigation entry or modal action. Saleor documents the available widget mounting points in Extending the Dashboard with Apps.

The url points to /product-widget, backed by src/pages/product-widget.tsx. After reinstalling the app, the widget appears in the Dashboard's Apps sidebar on the product detail page.

Step 3: Reading product context with GraphQL

To produce useful suggestions, the AI call needs three things:

  1. The product itself (name, description, SEO fields).
  2. The set of attributes the product can have (defined on its product type), including allowed choices for dropdown/multiselect attributes.
  3. The set of attributes that are currently filled on this specific product, so we can compute what is missing.

A single GraphQL query covers all three:

GRAPHQL
1query ProductContext($id: ID!) {
2 product(id: $id) {
3 name
4 description
5 productType {
6 productAttributes {
7 id
8 name
9 slug
10 inputType
11 withChoices
12 choices(first: 50) {
13 edges {
14 node {
15 name
16 slug
17 }
18 }
19 }
20 }
21 }
22 assignedAttributes(limit: 100) {
23 attribute {
24 id
25 slug
26 inputType
27 }
28 # inline fragments per type: plainTextValue, booleanValue,
29 # singleChoiceValue, multiChoiceValue, dateValue, …
30 # each carries __typename so the TypeScript union stays discriminable
31 }
32 }
33}

AssignedAttribute is a discriminated union in Saleor. Each concrete type carries a different value shape, so selecting __typename keeps the generated TypeScript union narrowable. The server-side hasValue check can then be a type-safe switch instead of a guess.

Step 4: A protected API route on the server side

The widget never calls the LLM or Saleor directly. It talks to a server-side route protected by createProtectedHandler from the App SDK:

TS
1// src/pages/api/product-attribute-suggestions.ts (excerpt)
2import { createProtectedHandler, type NextJsProtectedApiHandler } from "@saleor/app-sdk/handlers/next";
3
4import { ProductContextDocument } from "@/generated/graphql";
5import { createClient } from "@/lib/create-graphq-client";
6import { saleorApp } from "@/saleor-app";
7
8const handler: NextJsProtectedApiHandler = async (req, res, { authData }) => {
9 if (req.method !== "POST") return res.status(405).json({ error: "Method not allowed" });
10
11 const productId = typeof req.body?.productId === "string" ? req.body.productId : null;
12 if (!productId) return res.status(400).json({ error: "Missing productId" });
13
14 const client = createClient(authData.saleorApiUrl, async () => ({ token: authData.token }));
15 const result = await client.query(ProductContextDocument, { id: productId });
16
17 if (result.error) return res.status(502).json({ error: result.error.message });
18 if (!result.data?.product) return res.status(404).json({ error: "Product not found" });
19
20 // ...compute missing attributes, call the LLM, normalize suggestions...
21};
22
23export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_PRODUCTS"]);

createProtectedHandler handles the request boundary: it verifies the staff JWT from useAuthenticatedFetch, rejects requests without MANAGE_PRODUCTS, resolves the app token from the APL entry for the calling Saleor API URL, and keeps each request scoped to the right Saleor instance.

The GraphQL call uses the long-lived app token, not the staff user's short-lived token. The staff permission check has already happened, and the app token gives the server a stable identity.

Step 5: Computing what is actually missing

Once we have product context, we compute the attributes that are defined on the product type but empty on this product:

TS
1const filledSlugs = new Set(product.assignedAttributes.filter(hasValue).map((a) => a.attribute.slug));
2
3const missingAttributes = (product.productType.productAttributes ?? []).filter(
4 (attr) => !filledSlugs.has(attr.slug),
5);

hasValue switches on the __typename discriminator and returns true only when the corresponding value field is non-empty:

TS
1function hasValue(a: AssignedProductAttributeFragment): boolean {
2 switch (a.__typename) {
3 case "AssignedSingleChoiceAttribute":
4 return a.singleChoiceValue != null;
5 case "AssignedMultiChoiceAttribute":
6 return (a.multiChoiceValue?.length ?? 0) > 0;
7 case "AssignedPlainTextAttribute":
8 return (a.plainTextValue ?? "").trim().length > 0;
9 case "AssignedBooleanAttribute":
10 return a.booleanValue != null;
11 case "AssignedDateAttribute":
12 return a.dateValue != null;
13 // ...
14 default:
15 return true;
16 }
17}

Generated types make this safer: adding a new assigned attribute type later becomes a compile-time concern instead of a silent fallthrough.

For the first iteration we restrict the model to attribute input types we know we can write back safely:

TS
1const SUPPORTED_INPUT_TYPES = ["PLAIN_TEXT", "DROPDOWN", "MULTISELECT", "DATE", "BOOLEAN"] as const;

Anything else, such as numeric values with units, swatches, references, or files, is returned as unsupportedInputTypes so the UI can show a small "Skipped: ..." line.

Step 6: A grounded, structured AI call

The most important design decision is that the model is treated as an extractor, not an author. The prompt is built from product context plus a JSON-lines list of the missing attribute slots, including each attribute's id, inputType, and allowed choice slugs:

TS
1const attributesPrompt = supportedMissingAttributes
2 .map((attr) => {
3 const choices = (attr.choices?.edges ?? []).map((e) => e?.node).filter(Boolean);
4 return JSON.stringify({
5 id: attr.id,
6 slug: attr.slug,
7 name: attr.name,
8 inputType: attr.inputType,
9 valueRequired: attr.valueRequired,
10 choices: choices.map((c) => ({ slug: c.slug, name: c.name })),
11 });
12 })
13 .join("\n");

The model is called with structured output: a Zod schema describing the only shape accepted by the server. This example uses the Vercel AI SDK's generateObject with the OpenAI provider, but the same pattern works with any supported provider.

TS
1import { createOpenAI } from "@ai-sdk/openai";
2import { generateObject } from "ai";
3import { z } from "zod";
4
5const aiSuggestionSchema = z.object({
6 suggestions: z.array(
7 z.object({
8 attributeId: z.string(),
9 attributeSlug: z.string(),
10 inputType: z.enum(SUPPORTED_INPUT_TYPES),
11 evidence: z.string().min(1),
12 // OpenAI Responses' JSON schema mode requires every property in `required`,
13 // so we keep all value carriers present and nullable, then validate per inputType later.
14 valueString: z.string().nullable(),
15 valueStrings: z.array(z.string()).nullable(),
16 valueBoolean: z.boolean().nullable(),
17 }),
18 ),
19});
20
21const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
22
23const { object } = await generateObject({
24 model: openai(process.env.OPENAI_MODEL ?? "gpt-4o-mini"),
25 schema: aiSuggestionSchema,
26 system: [
27 "You extract only explicitly stated facts from product descriptions.",
28 "Never invent missing data.",
29 "If evidence is weak or absent, do not output a suggestion for that attribute.",
30 "For DROPDOWN/MULTISELECT, value must be choice slug(s) from the provided list only.",
31 "For DATE, output only YYYY-MM-DD when an exact date is explicitly present.",
32 "Include a short evidence quote copied from the text.",
33 ].join(" "),
34 prompt: [
35 `Product name: ${product.name}`,
36 "Description:",
37 descriptionText,
38 "",
39 "Missing attributes (JSON lines):",
40 attributesPrompt,
41 "",
42 "Return suggestions only for attributes supported by clear textual evidence.",
43 ].join("\n"),
44});

This gives the workflow three useful guardrails:

  • No hallucinated attribute IDs. The model sees a closed list of attribute slots.
  • No invented dropdown values. Choice-based attributes must use existing Saleor choice slugs.
  • Evidence per suggestion. Every suggestion carries a short quote from the product description.

The schema's nullable value carriers (valueString, valueStrings, valueBoolean) are normalized server-side into a strict per-inputType discriminated union before they reach the UI:

TS
1case "DATE": {
2 const value = s.valueString?.trim();
3 if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) break;
4 result.push({ /* ...DATE-shaped suggestion */ });
5 break;
6}

This is where AI output becomes regular, validated TypeScript data. Anything that fails the per-type checks is dropped.

Step 7: Showing suggestions in the widget

With the API route in place, the widget stays small. On mount, and whenever productId changes, it calls /api/product-attribute-suggestions and renders the results.

TSX
1const SuggestionsPanel = ({ productId }: { productId: string | null }) => {
2 const fetch = useAuthenticatedFetch();
3 const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
4 const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
5
6 const loadSuggestions = useCallback(
7 async (signal?: AbortSignal) => {
8 if (!productId) return;
9 setStatus("loading");
10 const response = await fetch("/api/product-attribute-suggestions", {
11 method: "POST",
12 headers: { "Content-Type": "application/json" },
13 body: JSON.stringify({ productId }),
14 signal,
15 });
16 const body = await response.json();
17 if (!response.ok || "error" in body) {
18 setStatus("error");
19 return;
20 }
21 setSuggestions(body.suggestions);
22 setStatus("ready");
23 },
24 [fetch, productId],
25 );
26
27 useEffect(() => {
28 if (!productId) return;
29 const controller = new AbortController();
30 void loadSuggestions(controller.signal);
31 return () => controller.abort();
32 }, [loadSuggestions, productId]);
33
34 // ...render suggestions...
35};

useAuthenticatedFetch automatically attaches the staff JWT and saleor-api-url headers to each request, so the protected handler has the context it needs.

For an audiobook whose description ends with "Published by Saleor Publishing", the widget can suggest the missing publisher attribute and show that quote as evidence next to the Apply button.

Step 8: Applying approved suggestions

Applying suggestions is a separate protected route. It re-validates the incoming payload with a Zod discriminated union, maps each approved suggestion into Saleor's AttributeValueInput shape, and calls productUpdate. No write logic lives in the client.

The mutation itself is small:

GRAPHQL
1mutation UpdateProductAttributes($id: ID!, $attributes: [AttributeValueInput!]) {
2 productUpdate(id: $id, input: { attributes: $attributes }) {
3 errors {
4 field
5 message
6 code
7 }
8 product {
9 id
10 }
11 }
12}

productUpdate.errors is checked explicitly. Saleor mutations can report validation failures inside the response body even on a 200 OK, so the error array is the source of truth.

When the mutation succeeds, the widget dispatches a Dashboard notification through App Bridge. The toast appears outside the iframe in the Dashboard's own notification system:

TS
1appBridge?.dispatch(
2 actions.Notification({
3 status: "success",
4 title: "Attributes updated",
5 text: `${applied} AI suggestion(s) applied. Reload the page to see them in the form.`,
6 }),
7);

The toast tells the user to reload because the Saleor Dashboard caches product data via Apollo. Even internal navigation can keep showing stale values until a full browser reload.

The key pattern is the boundary around the model: AI calls happen server-side, the model sees the actual product type instead of a blank slate, choice-based attributes use Saleor choice slugs, and the staff user stays in control. A production version could add per-suggestion checkboxes, inline editing, rejection reasons, or an audit log without changing the core architecture.

Where this can go

This demo focuses on one product page and one enrichment task, but the same pattern can support:

  • Bulk catalog enrichment for product imports or migration cleanup.
  • Attribute completeness scoring by product type, category, channel, or marketplace.
  • Feed readiness checks for marketplaces, product ads, and AI-driven commerce channels.
  • Review queues where rejected suggestions improve future prompts and rules.

AI is more useful when it sits close to the commerce model. Saleor's app system gives you a clean place to put that intelligence: inside the Dashboard, next to the data, behind the right permissions, and close to the final mutation.

Try it yourself

The demo app is open source and includes the GraphQL fragments, protected API routes, manifest entry, and a working pnpm dev setup with ngrok for local Saleor Cloud installs.

    Get more useful guides, tech insights, and free learning materials by subscribing to our list.
    All human-written!

    By registering you agree to our Privacy Policy.
    The form is protected by reCAPTCHA - Privacy Policy and Terms of Service.