Building Production-Ready Sitecore Marketplace Apps with Next.js
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
- Recap: Where We Left Off
- Understanding Extension Points in Depth
- Building a Standalone Application
- Creating Dashboard Widgets
- Building Custom Field Extensions
- Working with XM Cloud APIs
- Fetching Real Content Data
- Handling API Errors Gracefully
- State Management Best Practices
- 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.

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
- Keep it focused: One primary metric or action
- Make it actionable: Include a CTA button
- Update in real-time: Show live data when possible
- 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:
- Enable "Custom Field" extension point
- In Sitecore, add field to content template
- Set field type to "Marketplace Type → Plugin"
- 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.