Approved in Content Hub, Live on the Site: Event-Driven Asset Publishing with Triggers and Next.js Revalidation

Sitecore Content Hub DAM + Next.js — a working implementation

In most DAM-to-website setups, the moment the business cares about is approval. Legal signs off, the asset is marked Approved, and the campaign manager expects to see it on the site. What usually follows is a wait: for a cache to expire, for a scheduled job to run, or for someone to trigger a deploy.

This article builds the alternative. When an asset’s status changes in Content Hub, the site’s cache for the affected campaign is invalidated within seconds — no rebuild, no polling. It includes working code for the three moving parts, the Content Hub configuration that connects them, and the section I spent the most time on: what happens when something in the chain fails.

When this pattern applies. It fits sites that read approved assets from Content Hub at render time: campaign galleries, download centres, brand portals. If your pages instead store a link to an asset inside XM Cloud content, the page data only changes when someone saves and publishes it in XM Cloud, so a DAM event alone will not change what that page says. Work out which of the two you have before building.
Before you copy the code. Relation names, entity definition names, authentication and the exact shape of the payload Content Hub sends all vary by tenant. In the code they are marked TENANT-SPECIFIC or called out in a comment. Treat them as placeholders, and log the first real payload before trusting the parser.

Summary

Events should say that something changed, never carry the data. The site re-reads the truth from Content Hub, so a duplicated or late event cannot leave it wrong. Put a small Function between Content Hub and the site for validation and retries. Expire the cache immediately for webhooks. Keep a short TTL as a safety net. Plan the withdraw path as carefully as the approve path.

The scenario

Campaign X has a landing site with a gallery component that shows only Approved assets for the campaign. Two requirements:

  1. A newly approved asset appears on the site within seconds.
  2. A withdrawn asset disappears just as fast. A rejected or expired image on a live page is a legal problem, not a cosmetic one.

Requirement two is the harder one, and most write-ups of this pattern never mention it. We will come back to it.

Architecture and three design decisions

Figure 1 — From approval to a fresh page

Event-driven asset publishing flow When an asset is approved in Content Hub, a trigger calls an API action, which calls an Azure Function, which calls a Next.js route handler that invalidates the cache so the site re-fetches approved assets. CONTENT HUB Asset approved Lifecycle status changes Trigger Entity modified + condition Action: API call POST with entity values Azure Function Validate, tag, retry Next.js route handler Secret check, revalidateTag Next.js site Re-fetches approved assets Safety net: a short cache lifetime (TTL) bounds staleness if any step fails.

Fast path: event-driven invalidation. Slow path: the cache TTL.

1. Events say “something changed”, not “here is the data.” The pipeline carries only a cache tag. The site then re-queries Content Hub for the current list of approved assets. If an event is duplicated, delayed or arrives out of order, the worst outcome is a redundant refresh. The site can never end up showing data the event invented.

2. A small Azure Function sits between Content Hub and the site. You can point the Content Hub action straight at the Next.js route, and for a quick proof of concept that is fine. The Function earns its place for three reasons. Header values in a Content Hub action cannot be resolved from settings, so any secret placed there is stored in the action itself, and you want that to be a Function key you can rotate on its own, not the site’s revalidation secret. I also found no retry behaviour documented for API call actions, so retries belong in code you control. And the Function is the natural place for validation, tag mapping and logging.

3. A short cache lifetime stays in place as a safety net. The event is the fast path. A five-minute TTL on the data fetch means that if the Function is down, the site is down, or an event is lost, staleness is bounded instead of indefinite.

Step 1: Tag the data in Next.js

The site tags every Content Hub fetch with a broad tag and a per-campaign tag. Invalidating by tag means the webhook needs no knowledge of URLs.

lib/dam.ts

// lib/dam.ts  (server-side only)
import 'server-only';

const CH_BASE_URL = process.env.CH_BASE_URL!;                  // https://<your-tenant>.sitecorecontenthub.cloud
const APPROVED_STATUS_ID = process.env.CH_APPROVED_STATUS_ID!; // resolved once, see note below

export const DAM_TAG = 'dam';
export const campaignTag = (campaignId: string) => `dam:campaign:${campaignId}`;

export async function getApprovedAssets(campaignId: string) {
  // TENANT-SPECIFIC: relation names below ('CampaignToAsset') depend on your schema.
  const query =
    `Definition.Name=='M.Asset' AND ` +
    `Parent('FinalLifeCycleStatusToAsset').id==${APPROVED_STATUS_ID} AND ` +
    `Parent('CampaignToAsset').id==${campaignId}`;

  const url =
    `${CH_BASE_URL}/api/entities/query` +
    `?query=${encodeURIComponent(query)}&take=100&members=Title,FileName`;

  const res = await fetch(url, {
    headers: await authHeaders(), // use the auth method your tenant is set up for (not covered here)
    next: {
      tags: [DAM_TAG, campaignTag(campaignId)], // what the webhook invalidates
      revalidate: 300,                          // safety net: never stale for more than 5 minutes
    },
  });

  if (!res.ok) throw new Error(`Content Hub query failed: ${res.status}`);
  const data = await res.json();
  return data.items ?? [];
}

The query uses the Content Hub Query API. The approved status is a relation to a lifecycle status entity, so you filter on that entity’s id. Resolve the id once per environment, because it differs between tenants:

GET {CH_BASE_URL}/api/entities/query
    ?query=Definition.Name=='M.Final.LifeCycle.Status' AND String('StatusValue')=='Approved'

-> take the "id" of the single result and store it as CH_APPROVED_STATUS_ID

app/campaigns/[id]/page.tsx

// app/campaigns/[id]/page.tsx
import { getApprovedAssets } from '@/lib/dam';

export default async function CampaignGallery({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const assets = await getApprovedAssets(id);

  return (
    <ul>
      {assets.map((a: any) => (
        <li key={a.id}>{a.properties?.Title}</li> // shape depends on the 'members' you request
      ))}
    </ul>
  );
}
Check the response shape. The Query API caps take at 100, so a larger gallery needs paging. I have assumed the results come back in an items array and that properties appear according to the members you request, so confirm both against a real response before relying on them.

Step 2: The revalidation endpoint

This route does three jobs: it authenticates the caller, it accepts only tags this feature owns, and it expires them.

app/api/revalidate/route.ts

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';

// Only our own tags are accepted: 'dam' or 'dam:campaign:<id>'
const TAG_PATTERN = /^dam(:campaign:[A-Za-z0-9_-]{1,64})?$/;

function secretMatches(received: string | null): boolean {
  const expected = Buffer.from(process.env.REVALIDATE_SECRET ?? '');
  const actual = Buffer.from(received ?? '');
  return (
    expected.length > 0 &&
    expected.length === actual.length &&
    timingSafeEqual(expected, actual)
  );
}

export async function POST(request: NextRequest) {
  if (!secretMatches(request.headers.get('x-revalidate-secret'))) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }

  const body = await request.json().catch(() => null);
  const tags: unknown = body?.tags;

  if (
    !Array.isArray(tags) ||
    tags.length === 0 ||
    tags.length > 10 ||
    !tags.every((t) => typeof t === 'string' && TAG_PATTERN.test(t))
  ) {
    return NextResponse.json({ ok: false, error: 'Invalid tags' }, { status: 400 });
  }

  // { expire: 0 } = expire immediately (Next.js 16). See "The stale-while-revalidate trap".
  // On Next.js 15 the call is simply: revalidateTag(tag)
  for (const tag of tags as string[]) {
    revalidateTag(tag, { expire: 0 });
  }

  return NextResponse.json({ ok: true, revalidated: tags, at: Date.now() });
}

Two details matter here. The secret is compared in constant time. And the tag pattern means that even a leaked secret can only touch DAM cache entries, not the rest of the site.

The stale-while-revalidate trap. In Next.js 16, revalidateTag(tag, 'max') is the recommended form, and it marks data stale rather than expired. The first visitor after the change can still be served the old content while fresh data loads in the background. For a webhook where “live within seconds” is the requirement, the documentation points to { expire: 0 }. The trade-off is that the first request after invalidation waits while the page regenerates. For a rejected asset, that is the right trade. On Next.js 15 the call takes a single argument and expires immediately.

Step 3: The Azure Function

src/functions/damAssetChanged.ts

// src/functions/damAssetChanged.ts   (Azure Functions, Node.js v4 programming model)
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';

const MAX_ATTEMPTS = 3;
const ATTEMPT_TIMEOUT_MS = 3000;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function callRevalidate(tags: string[]): Promise<void> {
  let lastError: unknown;

  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    try {
      const res = await fetch(process.env.NEXT_REVALIDATE_URL!, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'x-revalidate-secret': process.env.REVALIDATE_SECRET!,
        },
        body: JSON.stringify({ tags }),
        signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS),
      });

      if (res.ok) return;
      lastError = new Error(`Site responded ${res.status}`);
      if (res.status < 500) break; // a 4xx will not fix itself, so do not retry
    } catch (err) {
      lastError = err; // network error or timeout: worth retrying
    }
    if (attempt < MAX_ATTEMPTS) await sleep(500 * 2 ** (attempt - 1)); // 0.5s, then 1s
  }
  throw lastError;
}

export async function damAssetChanged(
  request: HttpRequest,
  context: InvocationContext
): Promise<HttpResponseInit> {
  const body: any = await request.json().catch(() => null);
  const ctx = body?.context ?? body ?? {}; // log the first real payload and adjust this line

  // campaignId can hold several ids separated by '|' when an asset belongs to several campaigns
  const campaignIds = String(ctx.campaignId ?? '')
    .split('|')
    .map((id) => id.trim())
    .filter((id) => /^[A-Za-z0-9_-]{1,64}$/.test(id));

  // Fail broad, not silent: if we cannot tell which campaign changed, invalidate all DAM data.
  const tags = campaignIds.length > 0 ? campaignIds.map((id) => `dam:campaign:${id}`) : ['dam'];

  context.log('Content Hub event received', { campaignIds, tags });

  try {
    await callRevalidate(tags);
    return { status: 200, jsonBody: { ok: true, tags } };
  } catch (err) {
    context.error('Revalidation failed after retries', err);
    return { status: 502, jsonBody: { ok: false } };
  }
}

app.http('damAssetChanged', {
  methods: ['POST'],
  authLevel: 'function', // caller must send the function key in the x-functions-key header
  handler: damAssetChanged,
});

Three choices in this code are deliberate:

  • Client errors are not retried. A 401 or 400 will not fix itself, and retrying only delays the failure signal.
  • It fails broad, not silent. If the payload does not identify a campaign, it invalidates all DAM data. An unnecessary refresh is cheap. A missed one is the bug you are trying to prevent. A multi-valued campaign relation, which Content Hub separates with a pipe, also falls out naturally as several tags.
  • Worst-case duration is bounded. Three attempts at three seconds plus the backoff come to about ten and a half seconds. Set the timeout on the Content Hub action above that, or it will give up before the Function does.

Step 4: Configure Content Hub

The API URL in an action can be resolved from a setting, which keeps environment-specific URLs out of the action itself. Create the setting first:

Manage > Settings

Setting category:  Actions
Setting name:      ApiCalls
Value:
{
  "DamRevalidateUrl": "https://<your-function-app>.azurewebsites.net/api/damAssetChanged"
}

Then create the action under Manage > Actions:

Field Value Notes
Type API call Calls any external API.
Method POST POST, PUT and GET are supported.
API URL {Actions.ApiCalls.DamRevalidateUrl} Setting names are case-sensitive and cannot contain periods.
Timeout Longer than the Function’s worst case Expressed in seconds.
Headers x-functions-key = the Function key Headers cannot be resolved from settings, so this value is stored literally in the action.
Values campaignId = {CampaignToAsset} A relation name resolves to the target entity id, and several targets are separated by a pipe. Values arrive in a top-level context property. TENANT-SPECIFIC relation name. Keep the payload minimal: API call actions do not support multi-language properties, so avoid mapping those.

Then the trigger, under Manage > Triggers:

Field Value Notes
Objective Entity modification Fires on every update to an entity, so later edits to an already approved asset fire it too. That keeps metadata changes fresh, but expect more events.
Execution type In Background Runs asynchronously, so editors are never held up by your webhook.
Conditions M.Asset AND final lifecycle status = Approved If the condition builder does not expose the relation, use Advanced mode to edit the JSON.
Post actions The API call action above Added on the Actions tab.
Relation conditions fire from one side only. Content Hub produces a relation change event only on the side of the entity that changed. The documentation is explicit that a trigger observing a relation executes only when the relation is updated from the observed side. Approval is a relation between the asset and a lifecycle status, and it normally changes from the asset’s side, which is what this trigger watches. But do not assume it. Approve a real asset through every route your team actually uses (the normal approval flow, bulk actions, scripts) and confirm the trigger fires each time. If a route changes the relation from the other side, add that entity definition to the trigger conditions as well.

The withdraw path

Everything so far handles approval. Now requirement two. Create a second trigger that uses the same action, with conditions on the statuses that mean “no longer shown”. In a default setup that is, for example, Rejected and Archived, but check which final lifecycle values your tenant actually uses. Do not try to express this as “status is not Approved”. That condition matches every work-in-progress asset in the library and would fire on every edit.

Deleted assets are a gap. The Content Hub documentation notes that a trigger on entity deletion set to run In Background will not execute its actions, because the entity is gone before the conditions can be evaluated. If assets can be deleted outright, your webhook will never hear about it. Either adopt an archive-instead-of-delete policy, or accept that the TTL is the only thing covering deletes.

Step 5: Test it in layers

Test each hop on its own before you test the whole chain. When something fails end to end, you will know which link to look at.

Test the route

# 1. The Next.js route on its own
curl -i -X POST http://localhost:3000/api/revalidate \
  -H "content-type: application/json" \
  -H "x-revalidate-secret: $REVALIDATE_SECRET" \
  -d '{"tags":["dam:campaign:12345"]}'

# Expect 200. Repeat with a wrong secret (expect 401) and with {"tags":["anything-else"]} (expect 400).

Test the Function

# 2. The Function on its own, simulating what Content Hub sends
curl -i -X POST "https://<your-function-app>.azurewebsites.net/api/damAssetChanged" \
  -H "content-type: application/json" \
  -H "x-functions-key: $FUNCTION_KEY" \
  -d '{"context":{"campaignId":"12345"}}'

Then run the full chain, and time it:

  1. Approve a test asset and note the time. Reload the gallery until it appears. This is your real approval-to-live latency.
  2. Reject the same asset and measure the time to disappearance. This is the number legal cares about.
  3. Stop the site, approve an asset, restart the site, and confirm the asset appears within the TTL. This proves the safety net.
  4. Look at the Function logs for the first real payload and confirm that the parser extracted the campaign id correctly.

What happens when things fail

Failure What happens What to do
Function or site is down The Function retries three times, then returns 502. The site stays stale until the TTL expires. Alert on 502s. Check the action audit log in Content Hub. Staleness is bounded by the TTL.
Duplicate or burst events Several invalidations for the same tag. Harmless, because revalidation is idempotent. Nothing. This is why events carry a tag, not data.
Payload does not identify a campaign The Function invalidates the broad dam tag. Log it, then fix the Values mapping. Over-invalidating is the safe failure.
Asset withdrawn Handled by the second trigger. Test it as carefully as approval.
Asset deleted A background trigger on deletion does not run its actions. Archive instead of delete, or rely on the TTL.
Several self-hosted site instances Revalidation is local to the instance that received the call. Use a shared cache handler, or ensure every instance is invalidated. Managed platforms handle this for you.
Content Hub IP restrictions Outbound calls from Content Hub come from Azure public IP ranges. If you restrict the Function’s inbound traffic, allow the relevant Azure service tags.

When you need guaranteed delivery

The HTTP path is the right MVP: simple, fast, easy to debug. Its weakness is that if the Function is unreachable for longer than its retries, the event is simply lost, and the TTL has to cover for it. If that is not acceptable, Content Hub also offers Azure Service Bus actions. Swap the API call for a Service Bus action, and have the Function consume from the queue. You gain durable retries and a dead-letter queue for events that keep failing. You pay for it in extra infrastructure, so move to it when the TTL window is genuinely too long for your business, not before.

Pre-flight checklist

  1. You have confirmed that your site reads assets from Content Hub at render time, rather than storing links in XM Cloud content.
  2. Every Content Hub fetch is tagged, and also has a short TTL as a safety net.
  3. The revalidation route checks a secret in constant time and accepts only an allowlist of tags.
  4. Webhooks use immediate expiry, and the first-request-blocks trade-off has been accepted.
  5. The Function retries server errors, never client errors, and finishes before the action’s timeout.
  6. Both the approve path and the withdraw path have their own trigger, and both have been tested.
  7. You have decided how deletes are handled, and measured approval-to-live and rejection-to-gone latency.

Closing thoughts

None of the individual pieces here is complicated. A trigger, an action, a short Function, a ten-line route handler. The value is in the decisions around them: events that carry a tag rather than data, a TTL under the fast path, an equal-weight withdraw path, and an honest list of the cases the pattern does not cover.

A natural companion to this is the reverse problem: assets whose licences expire. It reuses the same pipeline with a different trigger, and it is where rights metadata from the previous article starts paying for itself. A topic for another post.

Popular Posts

Fetch component-level data in Next.js app using GraphQL - Sitecore Headless Development

All Blog Posts - 2024

Generate a sitemap for Sitecore Headless Next.js app

GraphQL query to fetch the country specific state list - Sitecore Headless Development