Reading time: 7 min read

Replacing xDB on SitecoreAI with a Marketplace app

SitecoreAI ships without xDB. How we replaced transactional storage, attribution, and funnel tracking with a small Marketplace-embedded app.

Portrait photo of Sohrab Saboori, article author

Most of xDB is a database, an API, and a UI

When we migrated a donation platform from Sitecore XP to SitecoreAI, most of the work was the migration everyone expects: MVC renderings became Next.js components, content moved to the Edge GraphQL endpoint, and hosting moved to Vercel. One part of the old platform had no direct equivalent on the new one: xDB.

On XP, xDB was doing a lot of unglamorous work for this site. Every donation and every support-form submission ended up stored in it. Campaign attribution was recorded there. Internal reports were built on top of it.

SitecoreAI does not include xDB or xConnect [2]. To be precise about what that means: the platform does ship with embedded analytics and personalization, and the entry-level tiers of Personalize, CDP, and Search are bundled rather than separately licensed [1]. For page metrics and variant testing, that covers real ground. What none of it does is store transactional records. Donations, inquiries, cancellations (the rows this platform kept in xDB) had nowhere to live, and that was the gap we had to fill.

We filled it with a small companion app: Postgres for storage, a plain HTTP event log for tracking, and a Sitecore Marketplace app wrapper so staff can use it without leaving SitecoreAI. This post covers how we scoped it, what we built, and what we left out.

Start by measuring what xDB actually did for you

It is easy to overestimate what you are losing. xDB can do contact merge, engagement value scoring, path analysis, and personalization. When we audited what this platform actually used, the list was much shorter:

  1. Durable storage of transactional records — donations and a set of support forms (inquiries, document requests, cancellations) submitted through the site.
  2. Campaign attribution — which campaign, source code, and channel each submission came from.
  3. Basic funnel numbers — page views, form impressions, submissions, and completed payments per campaign.
  4. An admin surface — somewhere staff could review records and investigate errors.

None of that needs a marketing automation suite. If your site leans on real personalization, the bundled Personalize tier (or its full version) is probably the right answer; this is not an argument against it. But for a workload that is essentially "store records, attribute them, count them," a CDP is a lot of product for a small job.

The shape of the replacement

We built a separate Next.js application, deployed independently from the head app, with four responsibilities:

  • Postgres (serverless) replaces the xDB collection database for the records we keep
  • API routes the head app calls server-to-server, authenticated with an API key
  • An admin dashboard where staff manage campaigns and review data
  • A Marketplace app registration, so that dashboard renders inside SitecoreAI
Donor's browser ──▶ head app (Next.js) ──▶ companion app API ──▶ Postgres
SitecoreAI UI ──(iframe, Marketplace SDK)──▶ companion app admin dashboard

One boundary decision did most of the heavy lifting for security and simplicity: the companion app has no public pages. The head app is the only public surface. Form submissions go to the head app's own API routes, which validate and forward them server-side. The companion app only ever trusts two things: its API key, and the Marketplace session for the admin UI.

Replacing interaction tracking with an explicit event log

On XP, tracking was implicit. The xDB tracker followed sessions around and you queried what it had collected. Our replacement is explicit on purpose: a single event endpoint, and the head app logs exactly the events we report on, nothing else.

// POST /api/analytics/events — accepts one event or a batch of up to 100.
// Called server-to-server by the head app; authenticated with an API key.
const eventSchema = z.object({
  campaignId: z.string().min(1),
  variantId: z.string().nullable().optional(),
  eventType: z.enum([
    'page_view',
    'form_impression',
    'form_submit',
    'payment_success',
    'payment_failed',
  ]),
  amount: z.number().int().positive().nullable().optional(),
  sessionId: z.string().max(64).nullable().optional(), // browser session, for dedup
  metadata: z.record(z.string(), z.unknown()).nullable().optional(),
});

const bodySchema = z.union([eventSchema, z.array(eventSchema).min(1).max(100)]);

export async function POST(request: NextRequest) {
  const apiKey = request.headers.get('x-api-key');
  if (!process.env.EVENTS_API_KEY || apiKey !== process.env.EVENTS_API_KEY) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // .catch(() => null): malformed JSON should fail validation below as a 400,
  // not throw a 500 before Zod ever runs.
  const parsed = bodySchema.safeParse(await request.json().catch(() => null));
  if (!parsed.success) {
    return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
  }

  const events = Array.isArray(parsed.data) ? parsed.data : [parsed.data];
  let created = 0;
  for (const event of events) {
    try {
      await prisma.analyticsEvent.create({ data: { ...event } });
      created++;
    } catch (err) {
      console.error('[analytics] failed to store event:', err);
      // keep going — one bad event should not sink the batch
    }
  }
  return NextResponse.json({ created }, { status: 201 });
}

This buys you a few things the old implicit tracker never did.

You own the schema. A funnel per campaign is a GROUP BY eventType away. Nobody has to learn a vendor query language.

You own retention. How long event data lives is a one-line policy decision, not a product setting.

The set of events is a contract. If it's not in the enum, it isn't collected, which makes privacy conversations much shorter.

The pattern generalizes: when a platform capability disappears in a migration, a boring log-shaped API that captures the data and lets you apply it later is often enough.

Embedding the admin app with the Marketplace SDK

The part that makes this feel like a platform feature rather than "yet another tool" is the Sitecore Marketplace. The companion app is registered as a Marketplace app, so SitecoreAI loads it in a sandboxed iframe inside its shell, with the SDK wiring the two up over the browser's postMessage API [3][4]. Editors open it from the same place they open the page editor.

Authentication rides on that: the Marketplace SDK provides the Sitecore identity to the embedded app, which we exchange for a session on our side (NextAuth with a custom provider, pure JWT sessions). There is no separate login and no local user table. If you can access the Sitecore organization, you can open the dashboard, and every action is attributed to your Sitecore email in an audit log.

Two practical notes from running this in production.

Iframe permissions matter. File downloads (CSV exports, generated QR images) silently fail inside the embedded frame until the app registration is granted the download permission. Budget time for a pass over every browser capability your dashboard uses.

Keep a break-glass path. If the iframe integration ever has a bad day, you still need to reach the admin UI. Ours allows direct login only from an allowlisted set of IPs — off by default, boring by design.

What we deliberately did not rebuild

Just as important as what we built:

  • Contact deduplication and identity merge
  • Engagement value and scoring
  • Path/journey analysis
  • Personalization rules (beyond what the bundled Personalize tier covers)

Nothing stops any of this from being added later. Each one is a product decision, not an incident of migration, and if they become real requirements, that's the point where the full CDP tier earns its keep. The mistake to avoid is accidentally rebuilding a marketing suite one feature request at a time.

Serverless notes that bit us

Serverless Postgres drivers may not support interactive transactions. Long-running BEGIN … COMMIT blocks are out; batched $transaction([...]) calls are fine. Design writes so each statement is independently safe.

Watch your bundler. The serverless database driver had to be listed in Next.js serverExternalPackages [5], which opts a dependency out of server bundling. Bundled incorrectly, it fails at runtime with confusing connection errors.

Where it landed

The migrated site launched with this setup and the XP/xDB pair was retired. Donations, support forms, attribution, and funnel reporting all run through the companion app; staff reach it from inside SitecoreAI and mostly don't think about it as a separate system, which was the goal.

The lesson we'd pass on: "SitecoreAI has no xDB" sounds like a blocker, but the fix is to scope the problem honestly. For us, the slice of xDB the platform actually used came down to a Postgres schema, one event endpoint, and a Marketplace registration.

Sources

  1. Sitecore Unveils SitecoreAI, Ushering in the AI-First Era of Digital Experience — Sitecore Newsroom
  2. Limitations and restrictions — Sitecore Documentation
  3. Marketplace SDK — Sitecore Documentation
  4. Sitecore/marketplace-sdk — GitHub
  5. serverExternalPackages — Next.js Documentation