Uniform workflow notifications in Slack
Last updated: December 23, 2025
Introduction
This How To guide describes how to automate the review process for content changes in Uniform by sending rich notifications to Slack channels when content creators request approval for their changes, get immediate visual feedback including screenshots, diff comparisons, and direct links to preview and edit content.
About this KB article
This article leans on using Next.js App Router, Slack, Open AI and Vercel for a complete tutorial. Of course, the approach can be ported to your own tech stack.
Key Benefits
Automated Notifications: No more manual pinging of reviewers
Visual Context: Screenshots of changes with before/after comparisons
AI-Powered Summaries: OpenAI generates human-readable descriptions of changes
Direct Access: One-click access to preview, diff view, and editor
Support for Multiple Content Types: Works with both Uniform compositions and entries
Architecture & How It Works
System Overview
Core Components
1. API Endpoint (/api/workflow-approval/route.ts)
Receives webhook data from Uniform
Uses Next.js
after()for asynchronous processingValidates and forwards data to processing pipeline
2. Main Processor (processWorkflowApproval.ts)
Routes requests based on entity type (composition vs entry)
Supports configurable entity types via
SUPPORTED_ENTITY_TYPESProvides shared configuration for screenshots and uploads
3. Type-Specific Processors
Composition Processor (processCompositionType.ts):
Handles Uniform composition changes
Fetches draft and published versions
Generates preview URLs for compositions
Entry Processor (processEntryType.ts):
Handles content entry changes (blog posts, etc.)
Maps entry types to preview paths
Validates content type preview configurations
4. Screenshot System (takeScreenshots.ts)
Uses Puppeteer with Chromium for reliable screenshots
Supports both local development and Vercel deployment
Advanced page loading detection with scroll-based lazy loading trigger
Handles network idle states and loading indicators
5. AI Description Generator (getOpenAIDescription.ts)
Creates JSON diffs between published and draft versions
Uses OpenAI GPT-4 to generate human-readable change descriptions
Focuses on user-facing changes while ignoring technical details
6. Slack Notification System (sendSlackNotification.ts)
Creates rich Slack message blocks
Includes screenshots, preview links, and diff comparisons
Handles both new content and content updates differently
Data Flow
Webhook Trigger: Uniform sends workflow stage change data
Content Fetching: System retrieves both draft and published versions
Screenshot Generation: Takes full-page screenshots of preview URLs
Asset Upload: Uploads screenshots to Vercel Blob storage
Change Analysis: Generates diff and AI-powered description
Notification: Sends rich Slack message with all context
Setup & Configuration
1. Environment Variables
# Required - Slack Integration
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK
# Required - UNIFORM_API_KEY=your-uniform-api-key
UNIFORM_PROJECT_ID=your-uniform-project-id
UNIFORM_PREVIEW_SECRET=your-preview-secret
UNIFORM_CLI_BASE_URL=https://uniform.app
# Required - Deployment
NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL=your-domain.com
VERCEL_TOKEN=your-vercel-token # For blob storage
# Optional, defaults to uniform.app# Required - AI Descriptions
OPENAI_API_KEY=sk-your-openai-key2. Slack Configuration
Create Incoming Webhook
Go to your Slack workspace settings
Navigate to “Apps” → “Manage” → “Custom Integrations”
Click “Incoming Webhooks” → “Add Configuration”
Select the channel for notifications
Copy the webhook URL to
SLACK_WEBHOOK_URL
Channel Setup
Create dedicated channel (e.g.,
#content-reviews)Add relevant team members
Consider channel permissions for sensitive content
3. Uniform Configuration

In your Uniform project:
Go to Settings → Webhooks
Add a new webhook:

Click “Create new Webhook” button
Enter Endpoint URL:
https://your-website.com/api/workflow-approvalSubscribe to events: Select
workflow.transactioneventClick “Create” button
Configure webhook transformation:

Open the created webhook
Go to Advanced tab
Enable transformation
Click on the “Edit transformation” button
Insert this code
function handler(webhook) { // Only proceed if the new stage is "In review" // STAGE NAME SHOULD BE CONFIGURED IN THE WORKFLOW if (webhook.payload.newStage.stageName !== 'In review') { webhook.cancel = true; return webhook; } return webhook; }
Webhook Payload Example:
{
"entity": {
"id": "composition-id",
"name": "Homepage",
"type": "component",
"url": "<https://canvas.uniform.app/>..."
},
"initiator": {
"email": "user@example.com",
"id": "user-id",
"name": "John Doe",
"is_api_key": false
},
"newStage": {
"stageId": "review",
"stageName": "Ready for Review",
"workflowId": "workflow-id",
"workflowName": "Content Workflow"
},
"previousStage": {
"stageId": "draft",
"stageName": "Draft",
"workflowId": "workflow-id",
"workflowName": "Content Workflow"
},
"project": {
"id": "project-id",
"url": "<https://canvas.uniform.app/>..."
},
"timestamp": "2024-01-01T10:00:00Z"
}4. Code Integration
Core Files Structure
src/
├── app/
│ ├── api/
│ │ └── workflow-approval/
│ │ └── route.ts
│ └── workflow-preview/
│ └── [type]/
│ ├── page.tsx
│ └── diff/
│ └── page.tsx
├── types/
│ └── workflowApproval.ts
└── utils/
└── workflow-approval/
├── processWorkflowApproval.ts
├── processCompositionType.ts
├── processEntryType.ts
├── sendSlackNotification.ts
├── takeScreenshots.ts
├── getOpenAIDescription.ts
└── getSerializedData.ts
Type Definitions (types/workflowApproval.ts)
export type WorkflowApprovalData = {
entity: {
id: string;
name: string;
type: string;
url: string;
};
initiator: {
email: string;
id: string;
is_api_key: boolean;
name: string;
};
newStage: {
stageId: string;
stageName: string;
workflowId: string;
workflowName: string;
};
previousStage: {
stageId: string;
stageName: string;
workflowId: string;
workflowName: string;
};
project: {
id: string;
url: string;
};
timestamp: string;
};
API Route (app/api/workflow-approval/route.ts)
import { after } from 'next/server';
import { WorkflowApprovalData } from '@/types/workflowApproval';
import processWorkflowApproval from '@/utils/workflow-approval/processWorkflowApproval';
export async function POST(request: Request) {
try {
const body = (await request.json()) as WorkflowApprovalData;
after(async () => {
await processWorkflowApproval(body);
});
return Response.json({ success: 'Request sent to process endpoint' });
} catch (error) {
console.error('Error in workflow-approval route:', error);
return Response.json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
Main Workflow Processor (utils/workflow-approval/processWorkflowApproval.ts)
import { WorkflowApprovalData } from '@/types/workflowApproval';
import processCompositionType from './processCompositionType';
import processEntryType from './processEntryType';
export const previewHost = process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`
: '<http://localhost:3000>';
export const VERCEL_FOLDER = 'workflow-approval';
const SUPPORTED_ENTITY_TYPES = ['component', 'entry'];
export const VERCEL_UPLOAD_OPTIONS = {
access: 'public',
addRandomSuffix: true,
} as const;
const processWorkflowApproval = async (workflowApprovalData: WorkflowApprovalData) => {
try {
console.info('Workflow approval process started');
const { entity } = workflowApprovalData;
if (!SUPPORTED_ENTITY_TYPES.includes(entity.type)) {
console.info(`Skipping non-supported entity type: ${entity.type}`);
return { success: true };
}
if (entity.type === 'component') {
return processCompositionType(workflowApprovalData);
}
if (entity.type === 'entry') {
return processEntryType(workflowApprovalData);
}
return { success: false, error: 'Unsupported entity type' };
} catch (error) {
console.error('Error in workflow-approval process:', error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
};
export default processWorkflowApproval;Composition Type Processor (utils/workflow-approval/processCompositionType.ts)
import { WorkflowApprovalData } from '@/types/workflowApproval';
import { CANVAS_PUBLISHED_STATE } from '@uniformdev/canvas';
import { put } from '@vercel/blob';
import getCompositionById from '../canvas/getCompositionById';
import getOpenAIDescription from './getOpenAIDescription';
import { previewHost, VERCEL_FOLDER, VERCEL_UPLOAD_OPTIONS } from './processWorkflowApproval';
import sendSlackNotification from './sendSlackNotification';
import takeScreenshots from './takeScreenshots';
const processCompositionType = async ({
entity,
initiator,
newStage,
previousStage,
timestamp,
project,
}: WorkflowApprovalData) => {
try {
console.info(`Processing composition: ${entity.name} (${entity.id})`);
const compositionId = entity.id;
const publishedComposition = await getCompositionById({
compositionId,
state: CANVAS_PUBLISHED_STATE,
}).catch(() => Promise.resolve(undefined));
const isNew = !publishedComposition;
const latestVersionPreviewUrl = `${previewHost}/workflow-preview/composition?isDraft=true&secret=${process.env.UNIFORM_PREVIEW_SECRET}&compositionId=${compositionId}`;
const latestPublishedVersionPreviewUrl = !isNew
? `${previewHost}/workflow-preview/composition?isDraft=false&secret=${process.env.UNIFORM_PREVIEW_SECRET}&compositionId=${compositionId}`
: undefined;
console.info('Taking screenshots...');
const { latestVersionScreenshot, latestPublishedVersionScreenshot } = await takeScreenshots(
latestVersionPreviewUrl,
latestPublishedVersionPreviewUrl
);
console.info('Screenshots taken successfully');
console.info('Uploading screenshots to Vercel Blob...');
const [uploadedLatestVersionScreenshot, uploadedLatestPublishedVersionScreenshot] = await Promise.all([
put(
`${VERCEL_FOLDER}/latest-version-${compositionId}.png`,
Buffer.from(latestVersionScreenshot),
VERCEL_UPLOAD_OPTIONS
),
!isNew
? put(
`${VERCEL_FOLDER}/latest-published-version-${compositionId}.png`,
Buffer.from(latestPublishedVersionScreenshot),
VERCEL_UPLOAD_OPTIONS
)
: Promise.resolve(undefined),
]);
console.info('Screenshots uploaded successfully');
console.info('Fetching composition versions...');
const changesDescription = !isNew
? await getOpenAIDescription({
type: 'composition',
id: compositionId,
})
: undefined;
const latestVersionScreenshotUrl = uploadedLatestVersionScreenshot.url;
const latestPublishedVersionScreenshotUrl = !isNew ? uploadedLatestPublishedVersionScreenshot.url : undefined;
const diffUrl = !isNew
? `${previewHost}/workflow-preview/composition/diff?latestVersionScreenshotUrl=${latestVersionScreenshotUrl}&latestPublishedVersionScreenshotUrl=${latestPublishedVersionScreenshotUrl}&secret=${process.env.UNIFORM_PREVIEW_SECRET}&id=${compositionId}`
: undefined;
console.info('Sending Slack notification...');
await sendSlackNotification({
type: 'composition',
entity,
initiator,
newStage,
previousStage,
timestamp,
project,
latestVersionScreenshotUrl,
latestPublishedVersionScreenshotUrl,
latestVersionPreviewUrl,
latestPublishedVersionPreviewUrl,
diffUrl,
changesDescription,
isNew,
});
console.info('Workflow approval process completed successfully');
return { success: true };
} catch (error) {
console.error('Error in workflow-approval process:', error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
};
export default processCompositionType;Entry Type Processor (utils/workflow-approval/processEntryType.ts)
import { WorkflowApprovalData } from '@/types/workflowApproval';
import { Entry } from '@uniformdev/canvas';
import { put } from '@vercel/blob';
import { contentClient, getEntryById } from '../canvas/serverOnlyContentClient';
import getOpenAIDescription from './getOpenAIDescription';
import { previewHost, VERCEL_FOLDER, VERCEL_UPLOAD_OPTIONS } from './processWorkflowApproval';
import sendSlackNotification from './sendSlackNotification';
import takeScreenshots from './takeScreenshots';
const getPreviewPath = (entry: Entry['entry']) => {
switch (entry.type) {
case 'blogPost':
return `/blogs/${entry._slug}`;
default:
return;
}
};
const processEntryType = async ({
entity,
initiator,
newStage,
previousStage,
timestamp,
project,
}: WorkflowApprovalData) => {
try {
const { id, name } = entity;
console.info(`Processing entry: ${name} (${id})`);
let entry = await getEntryById({ id, preview: false });
const isNew = !entry;
if (isNew) {
entry = await getEntryById({ id, preview: true });
}
if (!entry) {
console.error(`Entry not found: ${id}`);
return { success: false, error: 'Entry not found' };
}
const contentType = await contentClient
.getContentTypes()
.then(res => res.contentTypes.find(ct => ct.id === entry.type));
if (!contentType) {
console.error(`Content type not found: ${entry.type}`);
return { success: false, error: 'Content type not found' };
}
if (contentType.previewConfigurations.length === 0) {
console.error(`No preview configurations found for content type: ${entry.type}`);
return { success: false, error: 'No preview configurations found' };
}
const previewPath = getPreviewPath(entry);
if (!previewPath) {
console.error(`No preview path found for entry: ${entry.type}`);
return { success: false, error: 'No preview path found' };
}
const latestVersionPreviewUrl = `${previewHost}/workflow-preview/entry?isDraft=true&secret=${process.env.UNIFORM_PREVIEW_SECRET}&path=${previewPath}`;
const latestPublishedVersionPreviewUrl = !isNew
? `${previewHost}/workflow-preview/entry?isDraft=false&secret=${process.env.UNIFORM_PREVIEW_SECRET}&path=${previewPath}`
: undefined;
console.info('Taking screenshots...');
const { latestVersionScreenshot, latestPublishedVersionScreenshot } = await takeScreenshots(
latestVersionPreviewUrl,
latestPublishedVersionPreviewUrl
);
console.info('Screenshots taken successfully');
console.info('Uploading screenshots to Vercel Blob...');
const [uploadedLatestVersionScreenshot, uploadedLatestPublishedVersionScreenshot] = await Promise.all([
put(`${VERCEL_FOLDER}/latest-version-${id}.png`, Buffer.from(latestVersionScreenshot), VERCEL_UPLOAD_OPTIONS),
!isNew
? put(
`${VERCEL_FOLDER}/latest-published-version-${id}.png`,
Buffer.from(latestPublishedVersionScreenshot),
VERCEL_UPLOAD_OPTIONS
)
: Promise.resolve(undefined),
]);
console.info('Screenshots uploaded successfully');
console.info('Fetching composition versions...');
const changesDescription = !isNew
? await getOpenAIDescription({
type: 'entry',
id,
})
: undefined;
const latestVersionScreenshotUrl = uploadedLatestVersionScreenshot.url;
const latestPublishedVersionScreenshotUrl = !isNew ? uploadedLatestPublishedVersionScreenshot.url : undefined;
const diffUrl = !isNew
? `${previewHost}/workflow-preview/entry/diff?latestVersionScreenshotUrl=${latestVersionScreenshotUrl}&latestPublishedVersionScreenshotUrl=${latestPublishedVersionScreenshotUrl}&secret=${process.env.UNIFORM_PREVIEW_SECRET}&id=${id}`
: undefined;
console.info('Sending Slack notification...');
await sendSlackNotification({
type: 'entry',
entity,
initiator,
newStage,
previousStage,
timestamp,
project,
latestVersionScreenshotUrl,
latestPublishedVersionScreenshotUrl,
latestVersionPreviewUrl,
latestPublishedVersionPreviewUrl,
diffUrl,
changesDescription,
isNew,
});
} catch (error) {
console.error('Error in workflow-approval process:', error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
};
export default processEntryType;
Screenshot Utility (utils/workflow-approval/takeScreenshots.ts)
'use server';
import puppeteer from 'puppeteer';
import puppeteerCore from 'puppeteer-core';
import chromium from '@sparticuz/chromium-min';
const CHROMIUM_EXECUTABLE_PATH =
'<https://github.com/Sparticuz/chromium/releases/download/v133.0.0/chromium-v133.0.0-pack.tar>';
const VIEWPORT = { width: 1440, height: 900 };
const SCREENSHOT_WAIT_TIME = 5000; // Increased wait time to 5 seconds
const takeScreenshots = async (latestVersionPreviewUrl: string, latestPublishedVersionPreviewUrl?: string) => {
let browser;
if (process.env.VERCEL) {
const executablePath = await chromium.executablePath(CHROMIUM_EXECUTABLE_PATH);
browser = await puppeteerCore.launch({
executablePath,
args: [...chromium.args, '--disable-dev-shm-usage', '--disable-gpu'],
headless: chromium.headless,
defaultViewport: VIEWPORT,
});
} else {
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
defaultViewport: VIEWPORT,
});
}
try {
const page = await browser.newPage();
await page.setViewport(VIEWPORT);
// Enhanced page load timeout
page.setDefaultNavigationTimeout(60000);
// Helper function to properly capture a screenshot with full content loaded
const captureScreenshot = async (url: string) => {
await page.goto(url, { waitUntil: 'networkidle2' });
// Wait additional time for any JavaScript to execute
await new Promise(resolve => setTimeout(resolve, SCREENSHOT_WAIT_TIME));
// Scroll through the page to trigger lazy loading
await page.evaluate(() => {
// Smooth scroll down and back up to trigger any lazy loading
const scrollStep = Math.floor(window.innerHeight / 2);
const scrollHeight = document.body.scrollHeight;
return new Promise(resolve => {
let currentPosition = 0;
// Scroll down
const scrollDown = () => {
if (currentPosition < scrollHeight) {
window.scrollTo(0, currentPosition);
currentPosition += scrollStep;
setTimeout(scrollDown, 100);
} else {
currentPosition = scrollHeight;
window.scrollTo(0, currentPosition);
setTimeout(scrollUp, 500);
}
};
// Scroll back up
const scrollUp = () => {
if (currentPosition > 0) {
currentPosition -= scrollStep;
window.scrollTo(0, currentPosition);
setTimeout(scrollUp, 100);
} else {
window.scrollTo(0, 0);
setTimeout(resolve, 500);
}
};
scrollDown();
});
});
// Wait for network to be completely idle
await page.waitForFunction(
() => {
return document.readyState === 'complete';
},
{ timeout: 15000 }
);
// Optional: wait for any loading indicators to disappear
try {
// Adjust selector to match your loading indicators if needed
const loadingIndicators = ['.loading', '.spinner', '[data-loading="true"]'];
for (const selector of loadingIndicators) {
const loadingElement = await page.$(selector);
if (loadingElement) {
await page
.waitForSelector(selector, { hidden: true, timeout: 10000 })
.catch(() => console.info(`Waiting for ${selector} to hide timed out`));
}
}
} catch (e) {
console.info('No loading indicators found or timeout occurred', e);
}
// Take the screenshot with a small delay
await new Promise(resolve => setTimeout(resolve, 500));
return await page.screenshot({ fullPage: true, type: 'png' });
};
console.info(`Capturing screenshot for latest version: ${latestVersionPreviewUrl}`);
const latestVersionScreenshot = await captureScreenshot(latestVersionPreviewUrl);
if (!latestPublishedVersionPreviewUrl) {
return { latestVersionScreenshot };
}
console.info(`Capturing screenshot for published version: ${latestPublishedVersionPreviewUrl}`);
const latestPublishedVersionScreenshot = await captureScreenshot(latestPublishedVersionPreviewUrl);
return { latestVersionScreenshot, latestPublishedVersionScreenshot };
} catch (error) {
console.error('Error taking screenshots:', error);
throw error;
} finally {
await browser.close();
}
};
export default takeScreenshots;Slack Notification Service (utils/workflow-approval/sendSlackNotification.ts)
'use server';
import { WorkflowApprovalData } from '@/types/workflowApproval';
type SendSlackNotificationProps = WorkflowApprovalData & {
type: 'composition' | 'entry';
latestVersionScreenshotUrl: string;
latestPublishedVersionScreenshotUrl?: string;
latestVersionPreviewUrl: string;
latestPublishedVersionPreviewUrl?: string;
diffUrl?: string;
changesDescription?: string;
isNew: boolean;
};
const sendSlackNotification = async ({
entity,
initiator,
timestamp,
type,
latestVersionScreenshotUrl,
latestVersionPreviewUrl,
diffUrl,
changesDescription = 'No description provided',
isNew,
}: SendSlackNotificationProps) => {
const formattedDate = new Date(timestamp).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
});
if (!process.env.SLACK_WEBHOOK_URL) {
console.error('SLACK_WEBHOOK_URL is not set');
return;
}
const message = {
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: isNew
? `${initiator.name} created a new ${entity.name} for review on ${formattedDate}`
: `${initiator.name} requested review on the ${entity.name} on ${formattedDate}`,
emoji: true,
},
},
{ type: 'divider' },
...(isNew
? [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*✨ New created*\\nThis is a brand new ready for review.`,
},
},
]
: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*📝 What changed*\\n${changesDescription}`,
},
},
]),
{ type: 'divider' },
{ type: 'section', text: { type: 'mrkdwn', text: '*🖼️ Screenshot*' } },
{
type: 'image',
image_url: latestVersionScreenshotUrl,
alt_text: isNew ? "New " + type : 'Latest Version',
},
{ type: 'divider' },
{
type: 'section',
text: {
type: 'mrkdwn',
text: '*👀 Direct preview links*',
},
},
{
type: 'actions',
elements: [
...(isNew
? []
: [
{
type: 'button',
text: { type: 'plain_text', emoji: true, text: '🔺 Visual changes' },
url: diffUrl,
style: 'primary',
},
]),
{
type: 'button',
text: { type: 'plain_text', emoji: true, text: '👁️ Preview' },
url: latestVersionPreviewUrl,
style: 'primary',
},
{
type: 'button',
text: { type: 'plain_text', emoji: true, text: '✏️ Open Editor' },
url: entity.url,
style: 'primary',
},
],
},
{ type: 'divider' },
],
};
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
body: JSON.stringify(message),
});
};
export default sendSlackNotification;
AI Description Generator (utils/workflow-approval/getOpenAIDescription.ts)
'use server';
import { createPatch } from 'diff';
import getSerializedData from './getSerializedData';
const askOpenAI = async (patch: string) => {
const response = await fetch('<https://api.openai.com/v1/chat/completions>', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: "Bearer " + process.env.OPENAI_API_KEY,
},
body: JSON.stringify({
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'system',
content: `You are a content change analyzer specialized in analyzing JSON diffs. Your task is to identify and clearly describe meaningful content changes from a user perspective, while ignoring technical implementation details.
Focus on:
• Changes in text content and user-facing messages
• New or removed sections, pages, or content blocks
• Changes in navigation structure or menu items
• Updates to metadata directly visible to users (titles, descriptions, SEO-relevant content)
• Changes in visible links and URLs
• Modifications to images, media, or embedded content
• Changes in user-visible business logic or functionality (e.g., form behavior, content visibility rules)
Ignore:
• JSON structure, formatting, and rich text markup
• Whitespace, indentation, and visual styling details
• Internal IDs, references, and system-generated fields
• Metadata that doesn't affect visible content or SEO
• Version numbers, timestamps, and backend configuration details
Output Format:
• Use clear, concise language understandable by non-technical stakeholders.
• Limit your description to up to 5 sentences or 5 bullet points.
• If many changes occurred, prioritize the top 5 most impactful for users.
• Do not include personal interpretations, conclusions, or opinions—state only objective facts about what changed.`,
},
{
role: 'user',
content: "Please analyze these content changes and describe the meaningful updates in up to 5 sentences: " + patch,
},
],
temperature: 0.7,
max_tokens: 2000,
}),
});
const data = await response.json();
return data.choices[0].message.content;
};
type GetOpenAIDescriptionProps = {
type: 'composition' | 'entry';
id: string;
};
const getOpenAIDescription = async ({ type, id }: GetOpenAIDescriptionProps) => {
const [draftVersion, publishedVersion] = await Promise.all([
getSerializedData({ type, id, preview: true }),
getSerializedData({ type, id, preview: false }),
]);
console.info('Creating diff...');
const patch = createPatch('diff', publishedVersion, draftVersion, 'Published Version', 'Draft Version', {
ignoreWhitespace: true,
ignoreNewlineAtEof: true,
});
console.info('Getting OpenAI description...');
let changesDescription;
try {
changesDescription = await askOpenAI(patch);
} catch (error) {
console.error('Error getting OpenAI description:', error);
changesDescription = 'Unable to generate changes description. Please review the diff manually.';
}
return changesDescription;
};
export default getOpenAIDescription;Data Serialization Utility (utils/workflow-approval/getSerializedData.ts)
import { CANVAS_PUBLISHED_STATE } from '@uniformdev/canvas';
import { CANVAS_DRAFT_STATE } from '@uniformdev/canvas';
import getCompositionById from '../canvas/getCompositionById';
import { getEntryById } from '../canvas/serverOnlyContentClient';
type GetSerializedDataProps = {
type: 'composition' | 'entry';
id: string;
preview?: boolean;
};
const getSerializedData = async ({ type, id, preview }: GetSerializedDataProps) => {
if (type === 'composition') {
return getCompositionById({
compositionId: id,
state: preview ? CANVAS_DRAFT_STATE : CANVAS_PUBLISHED_STATE,
}).then(res => JSON.stringify(res, null, 2));
}
if (type === 'entry') {
return getEntryById({
id,
preview,
}).then(res => JSON.stringify(res, null, 2));
}
};
export default getSerializedData;Workflow Preview Page (app/workflow-preview/[type]/page.tsx)
import { notFound } from 'next/navigation';
import { CANVAS_DRAFT_STATE, CANVAS_PUBLISHED_STATE } from '@uniformdev/canvas';
import { UniformComposition } from '@uniformdev/canvas-next-rsc';
import resolve from '@/canvas/resolvers/app';
import getCompositionRoute from '@/utils/canvas/getCompositionRoute';
import { noCacheRouteClient } from '@/utils/canvas/routeClient';
type PageParameters = {
params: Promise<{
type?: 'composition' | 'entry';
}>;
searchParams: Promise<{ isDraft?: string; secret?: string; compositionId?: string; path?: string }>;
};
const getRoute = async ({
compositionId,
path,
type,
state,
}: {
compositionId?: string;
path?: string;
type: 'composition' | 'entry';
state: typeof CANVAS_DRAFT_STATE | typeof CANVAS_PUBLISHED_STATE;
}) => {
if (type === 'composition') {
return getCompositionRoute({
compositionId,
state,
});
}
if (type === 'entry') {
return noCacheRouteClient.getRoute({
path,
state,
...(state === CANVAS_DRAFT_STATE ? { dataSourceVariant: 'unpublished' } : {}),
});
}
};
const Page = async (props: PageParameters) => {
const searchParams = await props.searchParams;
const { type } = await props.params;
const { isDraft, secret, compositionId, path } = searchParams;
if (secret !== process.env.UNIFORM_PREVIEW_SECRET) return notFound();
const state = isDraft === 'true' ? CANVAS_DRAFT_STATE : CANVAS_PUBLISHED_STATE;
const route = await getRoute({
compositionId,
path,
type,
state,
});
if (!route) return notFound();
return (
<UniformComposition
{...props}
params={Promise.resolve({ path: type })}
searchParams={Promise.resolve({ workflowPreview: 'true' })}
route={route}
resolveComponent={resolve}
mode="server"
/>
);
};
export const dynamic = 'force-dynamic';
export const fetchCache = 'force-no-store';
export default Page;
Workflow Diff Preview Page (app/workflow-preview/[type]/diff/page.tsx)
import { notFound } from 'next/navigation';
import CompositionDiffViewer from '@/components/CompositionDiffViewer';
import getSerializedData from '@/utils/workflow-approval/getSerializedData';
type PageParameters = {
params: Promise<{ type?: 'composition' | 'entry' }>;
searchParams: Promise<{
draftVersionId?: string;
publishedVersionId?: string;
secret?: string;
latestVersionScreenshotUrl?: string;
latestPublishedVersionScreenshotUrl?: string;
id?: string;
}>;
};
const Page = async (props: PageParameters) => {
const { secret, latestVersionScreenshotUrl, latestPublishedVersionScreenshotUrl, id } = await props.searchParams;
const { type } = await props.params;
if (secret !== process.env.UNIFORM_PREVIEW_SECRET) return notFound();
const [draftVersion, publishedVersion] = await Promise.all([
getSerializedData({ type, id, preview: true }),
getSerializedData({ type, id, preview: false }),
]);
return (
<CompositionDiffViewer
draftVersion={draftVersion}
publishedVersion={publishedVersion}
latestVersionScreenshotUrl={latestVersionScreenshotUrl}
latestPublishedVersionScreenshotUrl={latestPublishedVersionScreenshotUrl}
/>
);
};
export const dynamic = 'force-dynamic';
export const fetchCache = 'force-no-store';
export default Page;Supporting Utility Files
The workflow system depends on several Uniform Canvas utility functions. Here are all the supporting utilities:
Canvas Client (utils/canvas/getCompositionById.ts)
'use server';
import { getCanvasClient } from '@uniformdev/canvas-next-rsc';
const canvasClient = getCanvasClient({
cache: {
type: 'revalidate',
interval: 0,
},
});
const getCompositionById = async ({ compositionId, state }: { compositionId: string; state: number }) => {
const { composition } = await canvasClient.getCompositionById({
compositionId,
state,
});
return composition;
};
export default getCompositionById;Content Client (utils/canvas/serverOnlyContentClient.ts)
'server-only';
import { CANVAS_DRAFT_STATE, CANVAS_PUBLISHED_STATE } from '@uniformdev/canvas';
import { ContentClient } from '@uniformdev/canvas';
export const contentClient = new ContentClient({
apiKey: process.env.UNIFORM_API_KEY,
apiHost: process.env.UNIFORM_CLI_BASE_URL,
projectId: process.env.UNIFORM_PROJECT_ID,
edgeApiHost: '<https://uniform.global>',
});
export const getEntryById = async ({ id, preview = false }: { preview?: boolean; id: string }) => {
return await contentClient
.getEntries({
filters: {
entityId: { eq: id },
},
state: preview ? CANVAS_DRAFT_STATE : CANVAS_PUBLISHED_STATE,
})
.then(res => res.entries?.[0]?.entry);
};Route Client (utils/canvas/routeClient.ts)
'server-only';
import { RouteClient } from '@uniformdev/canvas';
const routeClient = new RouteClient({
apiKey: process.env.UNIFORM_API_KEY,
projectId: process.env.UNIFORM_PROJECT_ID,
});
export const noCacheRouteClient = new RouteClient({
apiKey: process.env.UNIFORM_API_KEY,
projectId: process.env.UNIFORM_PROJECT_ID,
bypassCache: true,
disableSWR: true,
});
export default routeClient;
Project Map Client (utils/canvas/projectMapClient.ts)
import { ProjectMapClient } from '@uniformdev/project-map';
export const projectMapClient = new ProjectMapClient({
apiKey: process.env.UNIFORM_API_KEY,
apiHost: process.env.UNIFORM_CLI_BASE_URL,
projectId: process.env.UNIFORM_PROJECT_ID,
});Composition Route Utility (utils/canvas/getCompositionRoute.ts)
'use server';
import { CANVAS_DRAFT_STATE } from '@uniformdev/canvas';
import { projectMapClient } from './projectMapClient';
import routeClient from './routeClient';
// Utility function to construct full path from project map nodes
const constructFullPath = (nodes: any[], targetCompositionId: string): string | null => {
const targetNode = nodes.find(node => node.compositionId === targetCompositionId);
if (!targetNode) {
return null;
}
const segmentToPreviewMap = new Map<string, string>();
nodes.forEach(node => {
if (node.pathSegment && node.pathSegment.startsWith(':') && node.data?.previewValue) {
segmentToPreviewMap.set(node.pathSegment, node.data.previewValue);
}
});
let fullPath = targetNode.path;
// Replace each path parameter with its corresponding preview value
const pathSegments = fullPath.split('/');
const updatedSegments = pathSegments.map(segment => {
if (segment.startsWith(':')) {
// Look up the preview value for this specific parameter
const previewValue = segmentToPreviewMap.get(segment);
return previewValue || segment; // fallback to original segment if no preview found
}
return segment;
});
fullPath = updatedSegments.join('/');
return fullPath;
};
const getCompositionRoute = async ({ compositionId, state }: { compositionId: string; state: number }) => {
const projectMapNodes = await projectMapClient.getNodes({
includeAncestors: true,
compositionId,
expanded: true,
});
const path = constructFullPath(projectMapNodes.nodes, compositionId);
return routeClient.getRoute({
path,
state,
...(state === CANVAS_DRAFT_STATE ? { dataSourceVariant: 'unpublished' } : {}),
});
};
export default getCompositionRoute;5. Entry Type Configuration
Add New Entry Types
To support additional content types, modify the preview path mapping in processEntryType.ts:
const getPreviewPath = (entry: Entry['entry']) => {
switch (entry.type) {
case 'blogPost':
return "/blogs/" + entry._slug;
case 'landingPage':
return "pages/" + entry._slug;
case 'productPage':
return "/products/" + entry._slug;
default:
return;
}
};6. Dependencies
Required Packages
{
"dependencies": {
"@vercel/blob": "^0.x.x",
"puppeteer": "^21.x.x",
"puppeteer-core": "^21.x.x",
"@sparticuz/chromium-min": "^123.x.x",
"diff": "^5.x.x",
"@uniformdev/canvas": "^19.x.x",
"@uniformdev/canvas-next-rsc": "^19.x.x",
"@uniformdev/project-map": "^19.x.x"
}
}
Vercel Configuration
For Puppeteer to work on Vercel, ensure your vercel.json includes:
{
"functions": {
"src/app/api/workflow-approval/route.ts": {
"maxDuration": 60
}
}
}
7. Testing
Local Testing
Use ngrok or similar tool to expose local endpoint
Configure Uniform webhook to point to your tunnel URL
Trigger workflow changes in Uniform to test end-to-end
Debug Mode
Enable detailed logging by setting NODE_ENV=development and check console outputs for each processing step.
Customization Options
Slack Message Customization
Modify sendSlackNotification.ts to:
Change message format and styling
Add/remove action buttons
Customize notification content based on content type
Screenshot Configuration
Adjust takeScreenshots.ts for:
Different viewport sizes
Custom wait times
Mobile/desktop variants
Multiple device screenshots
AI Description Prompts
Customize OpenAI prompts in getOpenAIDescription.ts to:
Focus on specific types of changes
Adjust output format
Include domain-specific terminology
Content Type Support
Extend support for new content types by:
Adding type to
SUPPORTED_ENTITY_TYPESImplementing preview path mapping
Adding type-specific processing logic
Troubleshooting
Common Issues
Webhook Not Triggering
Verify webhook URL is accessible
Check Uniform webhook configuration
Ensure proper SSL/HTTPS setup
Screenshots Failing
Check memory limits on deployment platform
Verify Chromium executable permissions
Increase timeout values for slow-loading pages
Slack Messages Not Appearing
Validate webhook URL format
Check channel permissions
Verify message block structure
OpenAI Errors
Confirm API key is valid and has sufficient credits
Check rate limits
Verify model availability
Monitoring
Use Vercel function logs for debugging
Monitor OpenAI API usage
Set up Slack webhook failure notifications
Track screenshot upload success rates
Security Considerations
Store all sensitive keys in environment variables
Use preview secrets to protect preview URLs
Validate webhook payloads from Uniform
Implement rate limiting for API endpoints
Regularly rotate API keys and webhook URLs
This system provides a robust foundation for automated content review workflows with rich visual feedback and seamless integration between Uniform and Slack.