Building custom apps and the extension migration path
Building custom apps and the extension migration path
TL;DR:
- The app development loop: register in Developer Hub, scaffold with csdx app:create, develop locally in an iframe, deploy and install.
- Custom Field apps own a JSON data contract consumed by the Delivery API — design that shape carefully before first use.
- Migrating from legacy Extensions to Marketplace Apps requires updating the SDK import and adding location detection.
The gap between understanding Marketplace app architecture (covered in Lesson 2) and shipping a working app is the development workflow itself - scaffolding a project, wiring up the App SDK, testing inside the Contentstack UI, and deploying to production. This lesson walks through that workflow end to end, covering both Custom Field apps and Sidebar Widgets with concrete implementation patterns. It also addresses the migration path from Contentstack's legacy Extensions framework to the current Marketplace Apps model, which is relevant for teams maintaining older customizations.
The app development workflow
Building a Contentstack Marketplace app follows a consistent sequence, regardless of which location (Custom Field, Sidebar Widget, etc.) your app targets.
Step 1: Create the app in Developer Hub
Before writing any code, register the app in Developer Hub. Navigate to your organization's Developer Hub at https://app.contentstack.com/#!/developerhub and click New App. Provide:
- A descriptive app name (e.g., “PIM Product Selector” rather than “Custom Field 1”).
- The locations your app will support.
- The OAuth scopes it requires.
- A placeholder URL for each location - you will update this with your local development URL shortly.
This step generates an App UID that uniquely identifies your app within the Contentstack platform. You will need this UID during development and deployment.
Step 2: Scaffold the project with the CLI
Contentstack provides a CLI command to scaffold a new app project:
# Install the Contentstack CLI if you have not already npm install -g @contentstack/cli # Scaffold a new app project csdx app:create
The csdx app:create command prompts you for the app name, organization, and locations, then generates a project with:
- A React-based frontend (the default scaffold uses React, but you can use any framework).
- The @contentstack/app-sdk package pre-installed.
- Location-specific component stubs.
- A development server configuration.
The scaffold generates a directory structure like:
my-contentstack-app/
src/
components/
CustomField.tsx
SidebarWidget.tsx
AppConfiguration.tsx
App.tsx
package.json
tsconfig.jsonStep 3: Develop the UI component
Each location gets its own component. The component initializes the App SDK, reads context from the current location, and renders your custom UI. The specifics differ by location type, which we will cover in depth below.
Step 4: Connect to Contentstack via App SDK
Call ContentstackAppSdk.init() at the top level of your app and pass the resulting SDK instance down to your location components. The SDK provides all the methods your app needs to interact with Contentstack - reading entry data, writing field values, accessing stack configuration, and querying content types.
Step 5: Test in the Contentstack UI
Update your app's location URL in Developer Hub to point to your local development server (typically http://localhost:3000). Then install the app in a development stack. Open an entry in the Contentstack UI, and your app will render inside the iframe at the specified location.
This feedback loop - edit code locally, see changes in the Contentstack UI - is the core development experience. The iframe reloads when you save changes if your development server supports hot module replacement.
Step 6: Deploy and install
When the app is ready for production, deploy it to a hosting platform (Vercel, Netlify, AWS S3 + CloudFront, or Contentstack Launch) and update the location URLs in Developer Hub to point to the deployed URL. Then install the app in your production stack.
Building a Custom Field app
A Custom Field app replaces a standard field in the entry editor with your custom UI. The defining characteristic of a Custom Field is the data contract: your app owns a field value, and whatever data your app writes to that field is stored in the entry and returned via the Delivery API.
The data contract
When you add a Custom Field to a content type, Contentstack creates a field that stores a JSON value. Your app controls the shape of this JSON. The data your app writes via field.setData() is exactly what appears in the Delivery API response when a frontend application fetches the entry.
Common pitfall:
Changing the JSON shape of a Custom Field after entries are published breaks every frontend that consumes it. Treat the data structure as a versioned API contract and design it carefully before the first entry is saved.
This means you'll want to design the field's data shape carefully. If your Custom Field stores product data from a PIM system, the JSON value might look like:
{
"pimProductId": "PRD-98765",
"productName": "Wireless Headphones Pro",
"sku": "WHP-BLK-001",
"price": {
"amount": 149.99,
"currency": "USD"
},
"thumbnailUrl": "https://pim.example.com/images/whp-blk-001-thumb.jpg"
}Frontend applications consuming this entry via the Delivery API receive this exact JSON as the field's value. They do not need to know that the data originated from a Custom Field app - it is indistinguishable from any other JSON field value.
Implementation: Product Enrichment Custom Field
Let us build a Custom Field app that allows editors to search an external PIM system and import product data into the entry.
import ContentstackAppSdk from "@contentstack/app-sdk";
import { useState, useEffect, useCallback } from "react";
interface ProductData {
pimProductId: string;
productName: string;
sku: string;
price: { amount: number; currency: string };
thumbnailUrl: string;
}
interface SearchResult {
id: string;
name: string;
sku: string;
price: number;
currency: string;
thumbnail: string;
}
function ProductEnrichmentField() {
const [fieldInstance, setFieldInstance] = useState(null);
const [selectedProduct, setSelectedProduct] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [pimApiUrl, setPimApiUrl] = useState("");
useEffect(() => {
ContentstackAppSdk.init().then(async (sdk) => {
const customField = sdk.location.CustomField;
setFieldInstance(customField);
// Load the PIM API URL from app configuration
const installationData = await sdk.getInstallationData();
setPimApiUrl(installationData.configuration.pimApiUrl || "");
// Load existing field value if present
const existingData = customField.field.getData();
if (existingData && existingData.pimProductId) {
setSelectedProduct(existingData);
}
});
}, []);
const searchProducts = useCallback(async () => {
if (!searchQuery.trim() || !pimApiUrl) return;
setIsSearching(true);
try {
const response = await fetch(
`${pimApiUrl}/products/search?q=${encodeURIComponent(searchQuery)}`
);
const data = await response.json();
setSearchResults(data.results || []);
} catch (error) {
console.error("PIM search failed:", error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, [searchQuery, pimApiUrl]);
const selectProduct = useCallback(
async (result: SearchResult) => {
const productData: ProductData = {
pimProductId: result.id,
productName: result.name,
sku: result.sku,
price: { amount: result.price, currency: result.currency },
thumbnailUrl: result.thumbnail,
};
// Write the product data to the entry field
await fieldInstance.field.setData(productData);
setSelectedProduct(productData);
setSearchResults([]);
setSearchQuery("");
},
[fieldInstance]
);
const clearProduct = useCallback(async () => {
await fieldInstance.field.setData(null);
setSelectedProduct(null);
}, [fieldInstance]);
if (!fieldInstance) return ;
return (
);
} Key implementation details:
- field.getData() loads any previously selected product when the editor opens the entry.
- field.setData(productData) writes the structured product data to the field. This data persists in the entry and appears in the Delivery API response.
- App configuration stores the PIM API URL, so it does not need to be hardcoded. The configuration is set by the stack administrator when installing the app (see Lesson 2's section on App Configuration location).
- field.setData(null) clears the field when the editor removes the product.
Building a Sidebar Widget
A Sidebar Widget has a fundamentally different relationship with the entry than a Custom Field. A Custom Field owns a single field's data. A Sidebar Widget observes and optionally modifies the entire entry. It does not own any field - it operates alongside all of them.
Sidebar Widgets are appropriate for:
- Displaying computed information based on multiple fields (like the SEO Score widget in Lesson 2).
- Triggering external actions related to the current entry (sending to a translation service, requesting legal review).
- Showing related data from external systems (CRM contact details, analytics for the current URL).
Implementation: Translation Status Sidebar Widget
import ContentstackAppSdk from "@contentstack/app-sdk";
import { useState, useEffect } from "react";
interface TranslationStatus {
locale: string;
status: "pending" | "in_progress" | "completed" | "outdated";
lastTranslated: string | null;
wordCount: number;
}
function TranslationStatusWidget() {
const [statuses, setStatuses] = useState([]);
const [entryUid, setEntryUid] = useState("");
const [contentTypeUid, setContentTypeUid] = useState("");
const [translationApiUrl, setTranslationApiUrl] = useState("");
useEffect(() => {
ContentstackAppSdk.init().then(async (sdk) => {
const sidebar = sdk.location.SidebarWidget;
const entryData = sidebar.entry.getData();
setEntryUid(entryData.uid);
setContentTypeUid(sidebar.entry.content_type.uid);
// Load configuration
const installationData = await sdk.getInstallationData();
const apiUrl = installationData.configuration.translationApiUrl;
setTranslationApiUrl(apiUrl);
// Fetch translation status from external service
const response = await fetch(
`${apiUrl}/status?entryUid=${entryData.uid}&contentType=${sidebar.entry.content_type.uid}`
);
const data = await response.json();
setStatuses(data.locales || []);
});
}, []);
const requestTranslation = async (locale: string) => {
await fetch(`${translationApiUrl}/request`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entryUid,
contentTypeUid,
targetLocale: locale,
}),
});
// Update the status locally
setStatuses((prev) =>
prev.map((s) =>
s.locale === locale ? { ...s, status: "in_progress" } : s
)
);
};
return (
);
} This widget demonstrates a common Sidebar Widget pattern: read entry context from the App SDK, fetch supplementary data from an external service, display it alongside the entry, and provide actions (requesting translations) that interact with the external service.
The migration path from legacy Extensions to Marketplace Apps
Contentstack previously supported a feature called Extensions - custom fields and sidebar widgets that were configured directly within a stack's settings under Settings > Extensions. These legacy Extensions still function in existing stacks but are being superseded by the Marketplace Apps framework.
Key differences between Extensions and Marketplace Apps
| Capability | Legacy Extensions | Marketplace Apps |
|---|---|---|
| Registration | Per-stack, under Settings > Extensions | Per-organization, in Developer Hub |
| Authentication | Stack API key + management token | OAuth with scoped permissions |
| Locations | Custom Field, Sidebar Widget only | Custom Field, Sidebar Widget, Dashboard Widget, Full-Page App, App Configuration |
| Configuration | Limited (extension config JSON) | Full configuration UI via App Configuration location |
| Multi-stack | Must recreate in each stack | Install once, use across stacks in the organization |
| SDK | @contentstack/ui-extensions-sdk | @contentstack/app-sdk |
| Management | Stack-level only | Organization-level via Developer Hub |
Why migrate
- Multi-location support. Marketplace Apps can span multiple locations (Custom Field + Sidebar Widget + App Configuration) in a single app. Legacy Extensions are limited to one location per extension.
- OAuth authentication. Marketplace Apps use OAuth with explicit scopes, providing better security and auditability than management token-based authentication.
- Organization-level management. Marketplace Apps are managed at the organization level, allowing consistent deployment across multiple stacks without recreating the extension in each one.
- App Configuration. Marketplace Apps can include a dedicated configuration UI, eliminating the need for hardcoded values or separate configuration mechanisms.
- Future investment. Contentstack's ongoing development focuses on the Marketplace Apps platform. New features, SDK improvements, and documentation target the App SDK.
How to migrate
The migration from a legacy Extension to a Marketplace App involves these steps:
- Audit the existing extension. Identify what the extension does, what data it reads and writes, what external services it communicates with, and what configuration values it depends on.
- Create a new Marketplace App in Developer Hub. Register the app with the appropriate locations and OAuth scopes that match the extension's functionality.
- Update the SDK import. Replace the legacy Extensions SDK with the App SDK:
// Before (legacy Extensions SDK) import ContentstackUIExtension from "@contentstack/ui-extensions-sdk"; ContentstackUIExtension.init().then((extension) => { const fieldData = extension.field.getData(); extension.field.setData({ key: "value" }); }); // After (App SDK) import ContentstackAppSdk from "@contentstack/app-sdk"; ContentstackAppSdk.init().then((sdk) => { const customField = sdk.location.CustomField; const fieldData = customField.field.getData(); customField.field.setData({ key: "value" }); }); - Handle location detection. Unlike legacy Extensions, which always run in a single location, Marketplace Apps can run in multiple locations. Add location detection:
ContentstackAppSdk.init().then((sdk) => { if (sdk.location.CustomField) { // Custom field logic initCustomField(sdk.location.CustomField); } else if (sdk.location.SidebarWidget) { // Sidebar widget logic initSidebarWidget(sdk.location.SidebarWidget); } else if (sdk.location.AppConfigWidget) { // Configuration page logic initConfigPage(sdk.location.AppConfigWidget); } }); - Move configuration to App Configuration. If the legacy extension relied on hardcoded values or external configuration files, create an App Configuration location that stores these values. Stack administrators will configure them during installation.
- Test the migrated app. Install the Marketplace App in a development stack alongside the existing legacy extension. Verify that the app reads and writes the same data format so that existing entries remain compatible.
- Install in production and retire the legacy extension. Once validated, install the Marketplace App in the production stack, update the content type to use the new Custom Field, and remove the legacy extension.
Data compatibility during migration
The most critical aspect of migration is data compatibility. If the legacy extension stored data in a custom field, the Marketplace App must read and write the same JSON structure. If the legacy extension stored { "color": "#FF5733" }, the Marketplace App must handle that exact format. Changing the data format during migration would require updating every existing entry that uses the field, which can be a significant undertaking for content types with thousands of entries.
Common mistakes
- Ignoring the data contract in Custom Field apps. The JSON value your Custom Field writes is consumed directly by frontend applications via the Delivery API. Changing the shape of this JSON after entries are published breaks every frontend that consumes it. Design the data shape thoughtfully before the first entry is saved, and treat it as a versioned API contract.
- Hardcoding stack-specific values in the app code. API keys, content type UIDs, environment names, and external service URLs should never be hardcoded. Use the App Configuration location to store these values so that the same app code works across different stacks and organizations without modification.
- Skipping the legacy Extension audit before migration. Migrating an extension without fully understanding its behavior - what data it stores, what side effects it triggers, what configuration it depends on - leads to subtle bugs. Audit the existing extension thoroughly, including edge cases like empty fields, entries with no saved data, and error states.