Knowledge Base/Uniform workflow notifications in Slack

Uniform workflow notifications in Slack

how-toDeveloperPreviewNextJSWorkflow

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

Diagram

Core Components

1. API Endpoint (/api/workflow-approval/route.ts)

  • Receives webhook data from Uniform
  • Uses Next.js after() for asynchronous processing
  • Validates 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_TYPES
  • Provides 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

  1. Webhook Trigger: Uniform sends workflow stage change data
  2. Content Fetching: System retrieves both draft and published versions
  3. Screenshot Generation: Takes full-page screenshots of preview URLs
  4. Asset Upload: Uploads screenshots to Vercel Blob storage
  5. Change Analysis: Generates diff and AI-powered description
  6. 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-key

2. Slack Configuration

Create Incoming Webhook

  1. Go to your Slack workspace settings
  2. Navigate to “Apps” → “Manage” → “Custom Integrations”
  3. Click “Incoming Webhooks” → “Add Configuration”
  4. Select the channel for notifications
  5. 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

1.png

In your Uniform project:

  1. Go to Settings → Webhooks
  2. Add a new webhook:
2.png
    • Click “Create new Webhook” button
    • Enter Endpoint URL: https://your-website.com/api/workflow-approval
    • Subscribe to events: Select workflow.transaction event
    • Click “Create” button
  1. Configure webhook transformation: Open the created webhook
  2. 3.png
    1. Go to Advanced tab
    2. Enable transformation
    3. Click on the “Edit transformation” button
    4. 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://` : '<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: `); 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: ()`); const compositionId = entity.id; const publishedComposition = await getCompositionById({ compositionId, state: CANVAS_PUBLISHED_STATE, }).catch(() => Promise.resolve(undefined)); const isNew = !publishedComposition; const latestVersionPreviewUrl = `/workflow-preview/composition?isDraft=true&secret=&compositionId=`; const latestPublishedVersionPreviewUrl = !isNew ? `/workflow-preview/composition?isDraft=false&secret=&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( `/latest-version-.png`, Buffer.from(latestVersionScreenshot), VERCEL_UPLOAD_OPTIONS ), !isNew ? put( `/latest-published-version-.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 ? `/workflow-preview/composition/diff?latestVersionScreenshotUrl=&latestPublishedVersionScreenshotUrl=&secret=&id=` : 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/`; default: return; } }; const processEntryType = async ({ entity, initiator, newStage, previousStage, timestamp, project, }: WorkflowApprovalData) => { try { const { id, name } = entity; console.info(`Processing entry: ()`); 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: `); 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: `); return { success: false, error: 'Content type not found' }; } if (contentType.previewConfigurations.length === 0) { console.error(`No preview configurations found for content type: `); return { success: false, error: 'No preview configurations found' }; } const previewPath = getPreviewPath(entry); if (!previewPath) { console.error(`No preview path found for entry: `); return { success: false, error: 'No preview path found' }; } const latestVersionPreviewUrl = `/workflow-preview/entry?isDraft=true&secret=&path=`; const latestPublishedVersionPreviewUrl = !isNew ? `/workflow-preview/entry?isDraft=false&secret=&path=` : 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(`/latest-version-.png`, Buffer.from(latestVersionScreenshot), VERCEL_UPLOAD_OPTIONS), !isNew ? put( `/latest-published-version-.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 ? `/workflow-preview/entry/diff?latestVersionScreenshotUrl=&latestPublishedVersionScreenshotUrl=&secret=&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 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: `); const latestVersionScreenshot = await captureScreenshot(latestVersionPreviewUrl); if (!latestPublishedVersionPreviewUrl) { return { latestVersionScreenshot }; } console.info(`Capturing screenshot for published version: `); 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 ? ` created a new for review on ` : ` requested review on the on `, 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`, }, }, ]), { 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

  1. Use ngrok or similar tool to expose local endpoint
  2. Configure Uniform webhook to point to your tunnel URL
  3. 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:

  1. Adding type to SUPPORTED_ENTITY_TYPES
  2. Implementing preview path mapping
  3. 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.

Published: June 6, 2025