Tutorial - App building with AI
Build a Blog Post Metrics app with AI assistance
In this tutorial, we use an example of a custom app called Blog Post Metrics that provides the editors with word count and reading time indicator. These metrics are set to be displayed in the entry editor sidebar for a “Blog post” content type.
This tutorial builds on the Create a custom app tutorial. The end result is the same, and this version focuses on using AI assistance for development.
Prerequisites
Setup
- Node.js — Install an LTS version. If you don’t have Node.js, download it from the Node.js download page.
- Contentful account and space — If you don’t have an account, sign up.
- Content type — Create a content type named “Blog post” with the following field:
- Title — Short text (field ID:
title
). - Body — Long text (field ID:
body
).
- Title — Short text (field ID:
- AI coding agent — Use an AI coding agent of your choice.
Knowledge requirements:
- JavaScript — Ability to read and write JavaScript.
- React — Basic understanding of React.
- Content modeling — Familiarity with content modeling.
Create your project
Clone or download the Contentful Apps repository. This helps your AI agent generate code with more context. Open the repository in your IDE of choice.
In your AI agent chat, write the following prompt:
Create a new Contentful app project using the official create-contentful-app CLI tool.
Use the typescript template.
The project should be named "blog-post-metrics" and it should be inside the apps folder
This initializes your project with all required files.
- Navigate to the new folder and start the app:
cd blog-post-metrics
npm run start
- Go to the mentioned tutorial and follow the steps starting at Embed your app in the Contentful web app. Confirm that your app works, then return here. You should see something like this:
Retrieve, calculate, and display metrics
Next, update the Blog Post Metrics app to retrieve entry content, calculate metrics, and display them in the sidebar.
The app runs in the Entry sidebar location. Open src/locations/Sidebar.tsx
, which represents the sidebar location in the Contentful web app. For more information, see App locations.
In your AI agent chat, copy this prompt:
Create a simple React TypeScript component for a Contentful app sidebar that provides real-time blog post metrics.
The component should:
- Connect to the Contentful App SDK to access entry data
- Read content from a field called "body" and store it in React state
- Set up a listener that automatically updates the component whenever the content field changes
- Include a simple function that calculates reading time metrics (word count and estimated reading time in minutes)
- Use a reading speed of 200 words per minute for the calculation, use a constant for clarity
- Display the metrics in a simple UI using Contentful's design system
- Show both the word count and formatted reading time (e.g., "5 min read")
- Keep the code concise and simple — no complex error handling or loading states needed
- Use proper TypeScript typing throughout
The component should provide immediate feedback to content editors as they type.
This results in the following code:
import { useEffect, useState } from 'react';
import { Stack, Heading, Text } from '@contentful/f36-components';
import { SidebarAppSDK } from '@contentful/app-sdk';
import { useSDK } from '@contentful/react-apps-toolkit';
const WORDS_PER_MINUTE = 200;
const Sidebar = () => {
const sdk = useSDK<SidebarAppSDK>();
const [bodyContent, setBodyContent] = useState<string>('');
useEffect(() => {
// Get initial value from the body field
const bodyField = sdk.entry.fields.body;
const initialValue = bodyField.getValue() || '';
setBodyContent(initialValue);
// Set up listener for field changes
const unsubscribe = bodyField.onValueChanged((value: string) => {
setBodyContent(value || '');
});
// Cleanup listener on unmount
return () => {
unsubscribe();
};
}, [sdk.entry.fields.body]);
const calculateMetrics = (content: string) => {
const wordCount = content
.trim()
.split(/\s+/)
.filter((word) => word.length > 0).length;
const readingTime = Math.ceil(wordCount / WORDS_PER_MINUTE);
return {
wordCount,
readingTime: `${readingTime} min read`,
};
};
const metrics = calculateMetrics(bodyContent);
return (
<Stack spacing="spacingM" padding="spacingM">
<Heading>Blog Post Metrics</Heading>
<Stack spacing="spacingXs">
<Text fontWeight="fontWeightDemiBold">Word Count</Text>
<Text>{metrics.wordCount} words</Text>
</Stack>
<Stack spacing="spacingXs">
<Text fontWeight="fontWeightDemiBold">Reading Time</Text>
<Text>{metrics.readingTime}</Text>
</Stack>
</Stack>
);
};
export default Sidebar;
Improve the code
After reviewing, we notice that the useEffect
hook is unnecessary for reading the field value.
Prompt your AI agent with:
There's no need to read the field value from within a useEffect hook, as Contentful's SDK returns the value synchronously
This results in the following code:
import { useEffect, useState } from 'react';
import { Stack, Heading, Text } from '@contentful/f36-components';
import { SidebarAppSDK } from '@contentful/app-sdk';
import { useSDK } from '@contentful/react-apps-toolkit';
const WORDS_PER_MINUTE = 200;
const Sidebar = () => {
const sdk = useSDK<SidebarAppSDK>();
const [bodyContent, setBodyContent] = useState<string>(
sdk.entry.fields.body.getValue() || ''
);
useEffect(() => {
// Set up listener for field changes
const unsubscribe = sdk.entry.fields.body.onValueChanged(
(value: string) => {
setBodyContent(value || '');
}
);
// Cleanup listener on unmount
return () => {
unsubscribe();
};
}, [sdk.entry.fields.body]);
const calculateMetrics = (content: string) => {
const wordCount = content
.trim()
.split(/\s+/)
.filter((word) => word.length > 0).length;
const readingTime = Math.ceil(wordCount / WORDS_PER_MINUTE);
return {
wordCount,
readingTime: `${readingTime} min read`,
};
};
const metrics = calculateMetrics(bodyContent);
return (
<Stack spacing="spacingM" padding="spacingM">
<Heading>Blog Post Metrics</Heading>
<Stack spacing="spacingXs">
<Text fontWeight="fontWeightDemiBold">Word Count</Text>
<Text>{metrics.wordCount} words</Text>
</Stack>
<Stack spacing="spacingXs">
<Text fontWeight="fontWeightDemiBold">Reading Time</Text>
<Text>{metrics.readingTime}</Text>
</Stack>
</Stack>
);
};
export default Sidebar;
At this stage, you have a functioning app that you can preview locally.
Improve the UI
The UI might not look ideal. You can experiment with different AI prompts:
Option 1
The UI is not looking as expected (see screenshot).
Try using the Note component and adding one bullet point for each metric
Option 2
The UI is not looking as expected (see screenshot).
Take a look at other apps' Sidebar location and make it similar
In both prompts it is recommended to include a screenshot of the current UI.
The UI now looks like this:
Add a configuration screen
To make the app flexible, add a configuration screen for customizable parameters.
In your AI agent chat, copy this prompt:
Now let's add a configuration screen to make the app more flexible.
Update the ConfigScreen component to:
- Display a description of what the app does (blog post metrics with word count and reading time)
- Add a number input field for "Words per minute" with a default value of 200
- Save this value as an app parameter so it persists between sessions
- Include helpful text explaining what this setting controls
Then update the Sidebar component to:
- Read the words per minute value from the app parameters instead of using the hardcoded constant
- Use this configurable value in the reading time calculation
This will allow content editors to customize the reading speed calculation based on their target audience or content type.
This results in the following code:
ConfigScreen
import { useCallback, useState, useEffect } from 'react';
import { ConfigAppSDK } from '@contentful/app-sdk';
import {
Heading,
Form,
Paragraph,
Flex,
FormControl,
TextInput,
} from '@contentful/f36-components';
import { css } from 'emotion';
import { /* useCMA, */ useSDK } from '@contentful/react-apps-toolkit';
export interface AppInstallationParameters {
wordsPerMinute?: number;
}
const ConfigScreen = () => {
const [parameters, setParameters] = useState<AppInstallationParameters>({
wordsPerMinute: 200,
});
const sdk = useSDK<ConfigAppSDK>();
/*
To use the cma, inject it as follows.
If it is not needed, you can remove the next line.
*/
// const cma = useCMA();
const onConfigure = useCallback(async () => {
// This method will be called when a user clicks on "Install"
// or "Save" in the configuration screen.
// for more details see https://www.contentful.com/developers/docs/extensibility/ui-extensions/sdk-reference/#register-an-app-configuration-hook
// Get current the state of EditorInterface and other entities
// related to this app installation
const currentState = await sdk.app.getCurrentState();
return {
// Parameters to be persisted as the app configuration.
parameters,
// In case you don't want to submit any update to app
// locations, you can just pass the currentState as is
targetState: currentState,
};
}, [parameters, sdk]);
useEffect(() => {
// `onConfigure` allows to configure a callback to be
// invoked when a user attempts to install the app or update
// its configuration.
sdk.app.onConfigure(() => onConfigure());
}, [sdk, onConfigure]);
useEffect(() => {
(async () => {
// Get current parameters of the app.
// If the app is not installed yet, `parameters` will be `null`.
const currentParameters: AppInstallationParameters | null =
await sdk.app.getParameters();
if (currentParameters) {
setParameters(currentParameters);
}
// Once preparation has finished, call `setReady` to hide
// the loading screen and present the app to a user.
sdk.app.setReady();
})();
}, [sdk]);
return (
<Flex
flexDirection="column"
className={css({ margin: '80px', maxWidth: '800px' })}
>
<Form>
<Heading>Blog Post Metrics Configuration</Heading>
<Paragraph>
This app displays real-time blog post metrics in the sidebar,
including word count and estimated reading time. Configure the reading
speed below to match your target audience.
</Paragraph>
<FormControl>
<FormControl.Label isRequired>Words per minute</FormControl.Label>
<TextInput
type="number"
value={String(parameters.wordsPerMinute || 200)}
onChange={(e) =>
setParameters({
...parameters,
wordsPerMinute: parseInt(e.target.value) || 200,
})
}
/>
<FormControl.HelpText>
Average reading speed used to calculate estimated reading time.
Default is 200 words per minute (general audience). Use 250-300 for
faster readers or simpler content.
</FormControl.HelpText>
</FormControl>
</Form>
</Flex>
);
};
export default ConfigScreen;
Sidebar
import { useEffect, useState } from 'react';
import { Note, List } from '@contentful/f36-components';
import { SidebarAppSDK } from '@contentful/app-sdk';
import { useSDK } from '@contentful/react-apps-toolkit';
import { AppInstallationParameters } from './ConfigScreen';
const Sidebar = () => {
const sdk = useSDK<SidebarAppSDK>();
const [bodyContent, setBodyContent] = useState<string>(
sdk.entry.fields.body.getValue() || ''
);
const parameters = sdk.parameters.installation as AppInstallationParameters;
const wordsPerMinute = parameters.wordsPerMinute || 200;
useEffect(() => {
// Set up listener for field changes
const unsubscribe = sdk.entry.fields.body.onValueChanged(
(value: string) => {
setBodyContent(value || '');
}
);
// Cleanup listener on unmount
return () => {
unsubscribe();
};
}, [sdk.entry.fields.body]);
const calculateMetrics = (content: string) => {
const wordCount = content
.trim()
.split(/\s+/)
.filter((word) => word.length > 0).length;
const readingTime = Math.ceil(wordCount / wordsPerMinute);
return {
wordCount,
readingTime: `${readingTime} min read`,
};
};
const metrics = calculateMetrics(bodyContent);
return (
<Note title="Blog Post Metrics">
<List>
<List.Item>Word Count: {metrics.wordCount} words</List.Item>
<List.Item>Reading Time: {metrics.readingTime}</List.Item>
</List>
</Note>
);
};
export default Sidebar;
Fix configuration issues
The code above has a bug when updating the input, in which when the user tries to delete the current value from the input, the number 200 appears again automatically. In addition, the screen is not centered.
Prompt the AI agent with:
There's an issue with the Config Screen. When I try to delete the current value from the input, the number 200 appears again before I can enter another number.
I think we should remove the 200 as a fallback value in the input.
Add a validation that checks that the input value is a number in the onConfigure method.
Lastly, the config screen is not centered, make sure it's centered.
This yields the final corrected version:
import { useCallback, useState, useEffect } from 'react';
import { ConfigAppSDK } from '@contentful/app-sdk';
import {
Heading,
Form,
Paragraph,
Flex,
FormControl,
TextInput,
} from '@contentful/f36-components';
import { css } from 'emotion';
import { /* useCMA, */ useSDK } from '@contentful/react-apps-toolkit';
export interface AppInstallationParameters {
wordsPerMinute?: number;
}
const ConfigScreen = () => {
const [parameters, setParameters] = useState<AppInstallationParameters>({
wordsPerMinute: 200,
});
const sdk = useSDK<ConfigAppSDK>();
/*
To use the cma, inject it as follows.
If it is not needed, you can remove the next line.
*/
// const cma = useCMA();
const onConfigure = useCallback(async () => {
// This method will be called when a user clicks on "Install"
// or "Save" in the configuration screen.
// for more details see https://www.contentful.com/developers/docs/extensibility/ui-extensions/sdk-reference/#register-an-app-configuration-hook
// Validate that wordsPerMinute is a valid number
const wordsPerMinute = parameters.wordsPerMinute;
if (!wordsPerMinute || wordsPerMinute <= 0) {
sdk.notifier.error('Words per minute must be a valid positive number');
return false;
}
// Get current the state of EditorInterface and other entities
// related to this app installation
const currentState = await sdk.app.getCurrentState();
return {
// Parameters to be persisted as the app configuration.
parameters,
// In case you don't want to submit any update to app
// locations, you can just pass the currentState as is
targetState: currentState,
};
}, [parameters, sdk]);
useEffect(() => {
// `onConfigure` allows to configure a callback to be
// invoked when a user attempts to install the app or update
// its configuration.
sdk.app.onConfigure(() => onConfigure());
}, [sdk, onConfigure]);
useEffect(() => {
(async () => {
// Get current parameters of the app.
// If the app is not installed yet, `parameters` will be `null`.
const currentParameters: AppInstallationParameters | null =
await sdk.app.getParameters();
if (currentParameters) {
setParameters(currentParameters);
}
// Once preparation has finished, call `setReady` to hide
// the loading screen and present the app to a user.
sdk.app.setReady();
})();
}, [sdk]);
return (
<Flex
flexDirection="column"
className={css({ margin: '80px auto', maxWidth: '800px' })}
>
<Form>
<Heading>Blog Post Metrics Configuration</Heading>
<Paragraph>
This app displays real-time blog post metrics in the sidebar,
including word count and estimated reading time. Configure the reading
speed below to match your target audience.
</Paragraph>
<FormControl>
<FormControl.Label isRequired>Words per minute</FormControl.Label>
<TextInput
type="number"
value={
parameters.wordsPerMinute !== undefined
? String(parameters.wordsPerMinute)
: ''
}
onChange={(e) => {
const value = e.target.value;
setParameters({
...parameters,
wordsPerMinute: value === '' ? undefined : parseInt(value),
});
}}
/>
<FormControl.HelpText>
Average reading speed used to calculate estimated reading time.
Default is 200 words per minute (general audience). Use 250-300 for
faster readers or simpler content.
</FormControl.HelpText>
</FormControl>
</Form>
</Flex>
);
};
export default ConfigScreen;
The final result looks like this:
Wrap-up
You now have a Blog Post Metrics app that:
- Calculates and displays real-time metrics (word count and estimated reading time).
- Provides a configuration screen to adjust reading speed.
- Uses the Contentful design system for consistent UI.
Conclusions
In this tutorial, we’ve covered the importance of integrating AI assistance into software development while keeping a human in the loop. AI-generated code can accelerate workflows, but always requires careful review and adaptation. We also highlighted the value of using the Contentful apps repository for context when prompting AI agents.
- Keep a human in the loop: Always review AI-generated code for quality, security, and maintainability.
- Use the Contentful apps repository to give your AI assistant more context and ensure your app aligns with established patterns.
- Test thoroughly to ensure configuration options work as expected in different scenarios.