Building Production-Ready Sitecore Marketplace Apps with Next.js

Building production Sitecore Marketplace apps with extension points and XM Cloud API integration

In Part 1, we built our first Marketplace app and got it running in XM Cloud. Now it's time to take things to the next level—building production-ready apps that actually solve real problems.

We'll explore all four extension types, integrate with XM Cloud APIs, and write code that you can confidently deploy to production.

Table of Contents

  1. Recap: Where We Left Off
  2. Understanding Extension Points in Depth
  3. Building a Standalone Application
  4. Creating Dashboard Widgets
  5. Building Custom Field Extensions
  6. Working with XM Cloud APIs
  7. Fetching Real Content Data
  8. Handling API Errors Gracefully
  9. State Management Best Practices
  10. What's Next?

Recap: Where We Left Off

In Part 1, we created a basic analytics dashboard that:

  • Initialized the Marketplace SDK
  • Fetched application context
  • Displayed mock statistics
  • Ran successfully in XM Cloud Portal

Now we'll enhance this foundation with real functionality and explore other extension types.


Understanding Extension Points in Depth

Sitecore Marketplace offers four extension points. Each serves different use cases and user workflows.

Sitecore Extension Points

1. Standalone Application

Best For:

  • Admin dashboards and reporting tools
  • Bulk content operations
  • Configuration interfaces
  • Complex workflows that need dedicated space

User Access: Via "My Apps" in the main navigation

Screen Real Estate: Full page within the portal

2. Full-Screen Experience

Best For:

  • Content migration tools
  • Immersive workflows
  • Multi-step wizards
  • Tasks requiring maximum focus

User Access: Takes over the entire portal interface

Screen Real Estate: Complete viewport

3. Dashboard Widgets

Best For:

  • At-a-glance metrics
  • Quick status indicators
  • Action buttons for common tasks
  • Real-time notifications

User Access: Site dashboard cards

Screen Real Estate: Compact cards (typically 300-400px wide)

4. Page Builder Extensions

Custom Fields:

  • New field types (color pickers, icon selectors, etc.)
  • Enhanced input controls
  • Validation and formatting

Context Panels:

  • Content suggestions
  • SEO analysis
  • AI-powered recommendations
  • Workflow helpers

User Access: Content editor sidebar or field locations

Screen Real Estate: Varies by type


Building a Standalone Application

Let's enhance our analytics dashboard from Part 1 with real functionality.

Advanced Features

We'll add:

  • Real-time data updates
  • Multiple chart types
  • Date range filtering
  • Export functionality

Enhanced Dashboard Component

Update src/app/standalone/page.tsx:

"use client";

import { useMarketplaceClient } from "@/hooks/useMarketplaceClient";
import { ApplicationContext } from "@sitecore-marketplace-sdk/client";
import { useEffect, useState } from "react";
import { initializeXMC } from "@/lib/xmcClient";

interface ContentStats {
  totalItems: number;
  publishedToday: number;
  draftItems: number;
  scheduledPublishing: number;
  lastUpdated: Date;
}

interface ActivityItem {
  id: string;
  action: string;
  user: string;
  timestamp: Date;
}

export default function AnalyticsDashboard() {
  const { client, error, isInitialized, isLoading } = useMarketplaceClient();
  const [appContext, setAppContext] = useState<ApplicationContext | null>(null);
  const [stats, setStats] = useState<ContentStats | null>(null);
  const [activity, setActivity] = useState<ActivityItem[]>([]);
  const [refreshing, setRefreshing] = useState(false);

  // Fetch application context
  useEffect(() => {
    if (!isInitialized || !client) return;

    async function fetchContext() {
      try {
        const response = await client.query("application.context");
        setAppContext(response.data);
      } catch (err) {
        console.error("Failed to fetch context:", err);
      }
    }

    fetchContext();
  }, [client, isInitialized]);

  // Fetch content statistics
  useEffect(() => {
    if (!appContext || !client) return;

    fetchStats();

    // Auto-refresh every 30 seconds
    const interval = setInterval(fetchStats, 30000);
    return () => clearInterval(interval);
  }, [appContext, client]);

  async function fetchStats() {
    if (!client) return;
    
    setRefreshing(true);
    
    try {
      const xmc = await initializeXMC(client);

      // GraphQL query for content statistics
      const query = `
        query GetContentStats {
          search(
            where: {
              AND: [
                { name: "_path", value: "/sitecore/content/", operator: CONTAINS }
              ]
            }
            first: 1000
          ) {
            total
            results {
              id
              name
              updated
              published: field(name: "__Published") {
                value
              }
            }
          }
        }
      `;

      const response = await xmc.graphQL.query({ query });
      const items = response.data.search.results;
      
      // Calculate statistics
      const today = new Date();
      today.setHours(0, 0, 0, 0);

      const publishedToday = items.filter((item: any) => {
        const updateDate = new Date(item.updated);
        return updateDate >= today;
      }).length;

      setStats({
        totalItems: response.data.search.total,
        publishedToday,
        draftItems: items.filter((i: any) => !i.published?.value).length,
        scheduledPublishing: 0, // Would need workflow API
        lastUpdated: new Date(),
      });

      // Fetch recent activity
      const recentItems = items
        .slice(0, 5)
        .map((item: any) => ({
          id: item.id,
          action: "Updated",
          user: "Content Editor",
          timestamp: new Date(item.updated),
        }));

      setActivity(recentItems);

    } catch (err) {
      console.error("Failed to fetch stats:", err);
      // Fallback to mock data
      setStats({
        totalItems: 1247,
        publishedToday: 23,
        draftItems: 156,
        scheduledPublishing: 8,
        lastUpdated: new Date(),
      });
    } finally {
      setRefreshing(false);
    }
  }

  if (isLoading) {
    return <LoadingScreen />;
  }

  if (error) {
    return <ErrorScreen error={error} />;
  }

  return (
    <div className="min-h-screen bg-gray-50 p-6">
      <div className="max-w-7xl mx-auto">
        {/* Header with refresh */}
        <div className="flex items-center justify-between mb-8">
          <div>
            <h1 className="text-3xl font-bold text-gray-900">
              Content Analytics
            </h1>
            <p className="text-gray-600 mt-1">
              {appContext?.organization?.name || "Your Organization"}
            </p>
          </div>
          
          <button
            onClick={fetchStats}
            disabled={refreshing}
            className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
          >
            <RefreshIcon className={refreshing ? "animate-spin" : ""} />
            Refresh
          </button>
        </div>

        {/* Stats Grid */}
        {stats && (
          <>
            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
              <StatCard
                title="Total Items"
                value={stats.totalItems.toLocaleString()}
                icon="📊"
                trend="+12%"
                trendUp={true}
              />
              <StatCard
                title="Published Today"
                value={stats.publishedToday.toString()}
                icon="✅"
                trend="+5"
                trendUp={true}
              />
              <StatCard
                title="Draft Items"
                value={stats.draftItems.toString()}
                icon="📝"
                trend="-3"
                trendUp={false}
              />
              <StatCard
                title="Scheduled"
                value={stats.scheduledPublishing.toString()}
                icon="⏰"
                trend="→"
                trendUp={null}
              />
            </div>

            <p className="text-sm text-gray-500 mb-8">
              Last updated: {stats.lastUpdated.toLocaleTimeString()}
            </p>
          </>
        )}

        {/* Recent Activity */}
        <div className="bg-white rounded-lg shadow">
          <div className="px-6 py-4 border-b border-gray-200">
            <h2 className="text-xl font-semibold text-gray-900">
              Recent Activity
            </h2>
          </div>
          <div className="divide-y divide-gray-200">
            {activity.map((item) => (
              <div key={item.id} className="px-6 py-4 flex items-center justify-between">
                <div>
                  <p className="font-medium text-gray-900">{item.action}</p>
                  <p className="text-sm text-gray-500">by {item.user}</p>
                </div>
                <p className="text-sm text-gray-500">
                  {formatTimeAgo(item.timestamp)}
                </p>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

// Helper components
function StatCard({ title, value, icon, trend, trendUp }: any) {
  const trendColor = trendUp === true ? "text-green-600" : 
                     trendUp === false ? "text-red-600" : "text-gray-600";

  return (
    <div className="bg-white rounded-lg shadow p-6">
      <div className="flex items-center justify-between mb-4">
        <span className="text-3xl">{icon}</span>
        <span className={`text-sm font-medium ${trendColor}`}>
          {trend}
        </span>
      </div>
      <h3 className="text-gray-500 text-sm font-medium mb-2">{title}</h3>
      <p className="text-3xl font-bold text-gray-900">{value}</p>
    </div>
  );
}

function RefreshIcon({ className }: { className?: string }) {
  return (
    <svg className={`w-5 h-5 ${className}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
    </svg>
  );
}

function formatTimeAgo(date: Date): string {
  const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
  
  if (seconds < 60) return "just now";
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
  return `${Math.floor(seconds / 86400)}d ago`;
}

function LoadingScreen() {
  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-50">
      <div className="text-center">
        <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
        <p className="mt-4 text-gray-600">Loading dashboard...</p>
      </div>
    </div>
  );
}

function ErrorScreen({ error }: { error: Error }) {
  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-50">
      <div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
        <h2 className="text-red-800 font-semibold text-lg mb-2">
          ⚠️ Error Loading Dashboard
        </h2>
        <p className="text-red-600 text-sm">{error.message}</p>
      </div>
    </div>
  );
}

Creating Dashboard Widgets

Dashboard widgets are compact, focused components perfect for at-a-glance information.

Widget Design Principles

  1. Keep it focused: One primary metric or action
  2. Make it actionable: Include a CTA button
  3. Update in real-time: Show live data when possible
  4. Be responsive: Work on different screen sizes

Building a Content Approval Widget

Create src/app/dashboard-widget/page.tsx:

"use client";

import { useMarketplaceClient } from "@/hooks/useMarketplaceClient";
import { useEffect, useState } from "react";

interface ApprovalStats {
  pending: number;
  approved: number;
  rejected: number;
}

export default function ApprovalWidget() {
  const { client, isInitialized } = useMarketplaceClient();
  const [stats, setStats] = useState<ApprovalStats>({
    pending: 0,
    approved: 0,
    rejected: 0,
  });

  useEffect(() => {
    if (!isInitialized || !client) return;

    async function fetchApprovals() {
      // In production, fetch from workflow API
      setStats({
        pending: 8,
        approved: 45,
        rejected: 2,
      });
    }

    fetchApprovals();
    
    // Refresh every minute
    const interval = setInterval(fetchApprovals, 60000);
    return () => clearInterval(interval);
  }, [client, isInitialized]);

  return (
    <div className="p-4 bg-white rounded-lg h-full">
      <div className="flex items-center justify-between mb-4">
        <h3 className="text-lg font-semibold text-gray-900">
          Content Approvals
        </h3>
        <span className="text-2xl">📋</span>
      </div>
      
      <div className="space-y-3">
        <ApprovalRow
          label="Pending Review"
          count={stats.pending}
          color="orange"
          pulse={stats.pending > 0}
        />
        <ApprovalRow
          label="Approved Today"
          count={stats.approved}
          color="green"
        />
        <ApprovalRow
          label="Rejected"
          count={stats.rejected}
          color="red"
        />
      </div>
      
      <button className="mt-4 w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium">
        View All Workflows
      </button>
    </div>
  );
}

function ApprovalRow({ label, count, color, pulse }: any) {
  const colors = {
    orange: "bg-orange-100 text-orange-700",
    green: "bg-green-100 text-green-700",
    red: "bg-red-100 text-red-700",
  };

  return (
    <div className="flex items-center justify-between py-2">
      <span className="text-sm text-gray-600">{label}</span>
      <span className={`
        px-3 py-1 rounded-full text-sm font-semibold
        ${colors[color]}
        ${pulse ? 'animate-pulse' : ''}
      `}>
        {count}
      </span>
    </div>
  );
}

Register this widget:

  • Extension point: Dashboard widgets
  • Deployment URL: http://localhost:3000/dashboard-widget

Building Custom Field Extensions

Custom fields let you add new input types to the content editor.

Building a Color Picker Field

Create src/app/custom-field/page.tsx:

"use client";

import { useMarketplaceClient } from "@/hooks/useMarketplaceClient";
import { useEffect, useState } from "react";

const PRESET_COLORS = [
  { name: "Primary Blue", value: "#3B82F6" },
  { name: "Success Green", value: "#10B981" },
  { name: "Warning Orange", value: "#F59E0B" },
  { name: "Danger Red", value: "#EF4444" },
  { name: "Purple", value: "#8B5CF6" },
  { name: "Pink", value: "#EC4899" },
  { name: "Teal", value: "#14B8A6" },
  { name: "Gray", value: "#6B7280" },
];

export default function ColorPickerField() {
  const { client, isInitialized } = useMarketplaceClient();
  const [selectedColor, setSelectedColor] = useState("#3B82F6");
  const [customColor, setCustomColor] = useState("#3B82F6");

  useEffect(() => {
    if (!isInitialized || !client) return;

    async function initField() {
      try {
        // Get current field value
        const context = await client.query("field.context");
        if (context.data?.value) {
          setSelectedColor(context.data.value);
          setCustomColor(context.data.value);
        }
      } catch (err) {
        console.error("Failed to get field context:", err);
      }
    }

    initField();
  }, [client, isInitialized]);

  const updateColor = async (color: string) => {
    setSelectedColor(color);
    setCustomColor(color);

    if (!client) return;

    try {
      // Update field value in Sitecore
      await client.query("field.setValue", { value: color });
    } catch (err) {
      console.error("Failed to update field:", err);
    }
  };

  return (
    <div className="p-4 max-w-md">
      {/* Color Preview */}
      <div className="mb-6">
        <div
          className="w-full h-24 rounded-lg border-2 border-gray-200 shadow-sm"
          style={{ backgroundColor: selectedColor }}
        />
        <p className="text-center mt-3 text-lg font-mono font-semibold text-gray-700">
          {selectedColor}
        </p>
      </div>

      {/* Preset Colors */}
      <div className="mb-6">
        <label className="block text-sm font-medium text-gray-700 mb-3">
          Preset Colors
        </label>
        <div className="grid grid-cols-4 gap-3">
          {PRESET_COLORS.map((color) => (
            <button
              key={color.value}
              onClick={() => updateColor(color.value)}
              className={`
                h-12 rounded-lg transition-all
                ${selectedColor === color.value 
                  ? 'ring-2 ring-blue-500 ring-offset-2 scale-110' 
                  : 'hover:scale-105'}
              `}
              style={{ backgroundColor: color.value }}
              title={color.name}
            />
          ))}
        </div>
      </div>

      {/* Custom Color Input */}
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          Custom Color
        </label>
        <div className="flex gap-3">
          <input
            type="color"
            value={customColor}
            onChange={(e) => setCustomColor(e.target.value)}
            className="h-12 w-20 rounded-lg cursor-pointer"
          />
          <button
            onClick={() => updateColor(customColor)}
            className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 font-medium"
          >
            Apply Custom Color
          </button>
        </div>
      </div>
    </div>
  );
}

To use this field:

  1. Enable "Custom Field" extension point
  2. In Sitecore, add field to content template
  3. Set field type to "Marketplace Type → Plugin"
  4. Select your app from dropdown

Working with XM Cloud APIs

The XMC package gives you access to powerful content operations.

Initializing the XMC Client

Create src/lib/xmcClient.ts:

import { XMC } from "@sitecore-marketplace-sdk/xmc";
import { ClientSDK } from "@sitecore-marketplace-sdk/client";

let xmcInstance: XMC | null = null;

export async function initializeXMC(marketplaceClient: ClientSDK): Promise<XMC> {
  if (xmcInstance) return xmcInstance;

  xmcInstance = new XMC({ client: marketplaceClient });
  await xmcInstance.init();
  
  console.log("✅ XM Cloud API client initialized");
  return xmcInstance;
}

export function getXMC(): XMC {
  if (!xmcInstance) {
    throw new Error("XMC not initialized");
  }
  return xmcInstance;
}

Common API Operations

// Fetch content items
const query = `
  query GetItems {
    item(path: "/sitecore/content/home") {
      id
      name
      children {
        results {
          id
          name
          path
        }
      }
    }
  }
`;

const response = await xmc.graphQL.query({ query });

// Create a new item
const createMutation = `
  mutation CreateItem($input: CreateItemInput!) {
    createItem(input: $input) {
      id
      name
    }
  }
`;

await xmc.graphQL.query({
  query: createMutation,
  variables: {
    input: {
      name: "New Page",
      templateId: "template-id-here",
      parent: "parent-item-id"
    }
  }
});

Fetching Real Content Data

Let's implement a practical content browser component.

interface ContentItem {
  id: string;
  name: string;
  path: string;
  template: string;
  hasChildren: boolean;
}

export function ContentBrowser() {
  const { client } = useMarketplaceClient();
  const [items, setItems] = useState<ContentItem[]>([]);
  const [loading, setLoading] = useState(false);
  const [currentPath, setCurrentPath] = useState("/sitecore/content");

  async function loadItems(path: string) {
    if (!client) return;
    
    setLoading(true);
    
    try {
      const xmc = await initializeXMC(client);
      
      const query = `
        query GetChildren($path: String!) {
          item(path: $path) {
            id
            name
            children {
              results {
                id
                name
                path
                template {
                  name
                }
                hasChildren
              }
            }
          }
        }
      `;

      const response = await xmc.graphQL.query({
        query,
        variables: { path }
      });

      setItems(response.data.item.children.results);
      setCurrentPath(path);
    } catch (err) {
      console.error("Failed to load items:", err);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="p-6">
      <div className="mb-4">
        <p className="text-sm text-gray-600">Current: {currentPath}</p>
      </div>
      
      {loading ? (
        <div className="text-center py-8">Loading...</div>
      ) : (
        <div className="space-y-2">
          {items.map((item) => (
            <div
              key={item.id}
              onClick={() => item.hasChildren && loadItems(item.path)}
              className="p-3 bg-white rounded border hover:bg-gray-50 cursor-pointer"
            >
              <p className="font-medium">{item.name}</p>
              <p className="text-sm text-gray-500">{item.template}</p>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Handling API Errors Gracefully

Production apps need robust error handling.

Error Boundary Component

import { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="p-6 bg-red-50 border border-red-200 rounded-lg">
          <h2 className="text-lg font-semibold text-red-800 mb-2">
            Something went wrong
          </h2>
          <p className="text-red-600 text-sm">
            {this.state.error?.message}
          </p>
          <button
            onClick={() => this.setState({ hasError: false, error: null })}
            className="mt-4 px-4 py-2 bg-red-600 text-white rounded"
          >
            Try Again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

API Error Handler

export async function handleAPICall<T>(
  apiCall: () => Promise<T>,
  fallbackValue?: T
): Promise<T> {
  try {
    return await apiCall();
  } catch (error) {
    console.error("API call failed:", error);
    
    if (fallbackValue !== undefined) {
      return fallbackValue;
    }
    
    throw error;
  }
}

// Usage
const data = await handleAPICall(
  () => xmc.graphQL.query({ query }),
  [] // fallback to empty array
);

State Management Best Practices

For complex apps, consider using a state management library.

Using React Context

import { createContext, useContext, useState, ReactNode } from 'react';

interface AppState {
  stats: ContentStats | null;
  setStats: (stats: ContentStats) => void;
  refreshData: () => Promise<void>;
}

const AppContext = createContext<AppState | null>(null);

export function AppProvider({ children }: { children: ReactNode }) {
  const [stats, setStats] = useState<ContentStats | null>(null);

  const refreshData = async () => {
    // Refresh logic here
  };

  return (
    <AppContext.Provider value={{ stats, setStats, refreshData }}>
      {children}
    </AppContext.Provider>
  );
}

export function useAppState() {
  const context = useContext(AppContext);
  if (!context) throw new Error("useAppState must be used within AppProvider");
  return context;
}

What's Next?

You now have the skills to build production-ready Marketplace apps with:

  • All four extension types
  • Real XM Cloud API integration
  • Proper error handling
  • State management

In Part 3, we'll cover:

  • Deploying to production (Vercel, Netlify, Azure)
  • Performance optimization techniques
  • Security best practices
  • Monitoring and analytics
  • Publishing to public Marketplace

Quick Checklist

Before deploying, make sure you have:

  • Error boundaries in place
  • Loading states for all async operations
  • Fallback data for API failures
  • Responsive design tested
  • Console.log statements removed
  • TypeScript errors resolved

Next: Part 3 - Deploying and Optimizing Marketplace Apps →


Found this helpful? Follow for Part 3 where we deploy to production!

Have questions? Drop them in the comments below.

Read more