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).
  • 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.

Note: Different agents and models yield different results. You might not see identical results to those shown here and you may need to tweak the prompts for your own case, but the approach applies universally.

Create your project

  1. 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.

  2. In your AI agent chat, write the following prompt:

1Create a new Contentful app project using the official create-contentful-app CLI tool.
2Use the typescript template.
3The project should be named "blog-post-metrics" and it should be inside the apps folder

This initializes your project with all required files.

  1. Navigate to the new folder and start the app:
$cd blog-post-metrics
$npm run start
  1. 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:

Step 0 screenshot

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.

Important: Use the Contentful design system, Forma 36, to align the app with the Contentful web app.

In your AI agent chat, copy this prompt:

1Create a simple React TypeScript component for a Contentful app sidebar that provides real-time blog post metrics.
2The component should:
3
4- Connect to the Contentful App SDK to access entry data
5- Read content from a field called "body" and store it in React state
6- Set up a listener that automatically updates the component whenever the content field changes
7- Include a simple function that calculates reading time metrics (word count and estimated reading time in minutes)
8- Use a reading speed of 200 words per minute for the calculation, use a constant for clarity
9- Display the metrics in a simple UI using Contentful's design system
10- Show both the word count and formatted reading time (e.g., "5 min read")
11- Keep the code concise and simple — no complex error handling or loading states needed
12- Use proper TypeScript typing throughout
13
14The component should provide immediate feedback to content editors as they type.

This results in the following code:

1import { useEffect, useState } from 'react';
2import { Stack, Heading, Text } from '@contentful/f36-components';
3import { SidebarAppSDK } from '@contentful/app-sdk';
4import { useSDK } from '@contentful/react-apps-toolkit';
5
6const WORDS_PER_MINUTE = 200;
7
8const Sidebar = () => {
9 const sdk = useSDK<SidebarAppSDK>();
10 const [bodyContent, setBodyContent] = useState<string>('');
11
12 useEffect(() => {
13 // Get initial value from the body field
14 const bodyField = sdk.entry.fields.body;
15 const initialValue = bodyField.getValue() || '';
16 setBodyContent(initialValue);
17
18 // Set up listener for field changes
19 const unsubscribe = bodyField.onValueChanged((value: string) => {
20 setBodyContent(value || '');
21 });
22
23 // Cleanup listener on unmount
24 return () => {
25 unsubscribe();
26 };
27 }, [sdk.entry.fields.body]);
28
29 const calculateMetrics = (content: string) => {
30 const wordCount = content
31 .trim()
32 .split(/\s+/)
33 .filter((word) => word.length > 0).length;
34 const readingTime = Math.ceil(wordCount / WORDS_PER_MINUTE);
35
36 return {
37 wordCount,
38 readingTime: `${readingTime} min read`,
39 };
40 };
41
42 const metrics = calculateMetrics(bodyContent);
43
44 return (
45 <Stack spacing="spacingM" padding="spacingM">
46 <Heading>Blog Post Metrics</Heading>
47 <Stack spacing="spacingXs">
48 <Text fontWeight="fontWeightDemiBold">Word Count</Text>
49 <Text>{metrics.wordCount} words</Text>
50 </Stack>
51 <Stack spacing="spacingXs">
52 <Text fontWeight="fontWeightDemiBold">Reading Time</Text>
53 <Text>{metrics.readingTime}</Text>
54 </Stack>
55 </Stack>
56 );
57};
58
59export 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:

1There'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:

1import { useEffect, useState } from 'react';
2import { Stack, Heading, Text } from '@contentful/f36-components';
3import { SidebarAppSDK } from '@contentful/app-sdk';
4import { useSDK } from '@contentful/react-apps-toolkit';
5
6const WORDS_PER_MINUTE = 200;
7
8const Sidebar = () => {
9 const sdk = useSDK<SidebarAppSDK>();
10 const [bodyContent, setBodyContent] = useState<string>(
11 sdk.entry.fields.body.getValue() || ''
12 );
13
14 useEffect(() => {
15 // Set up listener for field changes
16 const unsubscribe = sdk.entry.fields.body.onValueChanged(
17 (value: string) => {
18 setBodyContent(value || '');
19 }
20 );
21
22 // Cleanup listener on unmount
23 return () => {
24 unsubscribe();
25 };
26 }, [sdk.entry.fields.body]);
27
28 const calculateMetrics = (content: string) => {
29 const wordCount = content
30 .trim()
31 .split(/\s+/)
32 .filter((word) => word.length > 0).length;
33 const readingTime = Math.ceil(wordCount / WORDS_PER_MINUTE);
34
35 return {
36 wordCount,
37 readingTime: `${readingTime} min read`,
38 };
39 };
40
41 const metrics = calculateMetrics(bodyContent);
42
43 return (
44 <Stack spacing="spacingM" padding="spacingM">
45 <Heading>Blog Post Metrics</Heading>
46 <Stack spacing="spacingXs">
47 <Text fontWeight="fontWeightDemiBold">Word Count</Text>
48 <Text>{metrics.wordCount} words</Text>
49 </Stack>
50 <Stack spacing="spacingXs">
51 <Text fontWeight="fontWeightDemiBold">Reading Time</Text>
52 <Text>{metrics.readingTime}</Text>
53 </Stack>
54 </Stack>
55 );
56};
57
58export default Sidebar;

At this stage, you have a functioning app that you can preview locally.

Step 1 screenshot

Improve the UI

The UI might not look ideal. You can experiment with different AI prompts:

Option 1

1The UI is not looking as expected (see screenshot).
2Try using the Note component and adding one bullet point for each metric

Option 2

1The UI is not looking as expected (see screenshot).
2Take 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:

Step 2 video

Add a configuration screen

To make the app flexible, add a configuration screen for customizable parameters.

In your AI agent chat, copy this prompt:

1Now let's add a configuration screen to make the app more flexible.
2Update the ConfigScreen component to:
3
4- Display a description of what the app does (blog post metrics with word count and reading time)
5- Add a number input field for "Words per minute" with a default value of 200
6- Save this value as an app parameter so it persists between sessions
7- Include helpful text explaining what this setting controls
8
9Then update the Sidebar component to:
10
11- Read the words per minute value from the app parameters instead of using the hardcoded constant
12- Use this configurable value in the reading time calculation
13
14This 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

1import { useCallback, useState, useEffect } from 'react';
2import { ConfigAppSDK } from '@contentful/app-sdk';
3import {
4 Heading,
5 Form,
6 Paragraph,
7 Flex,
8 FormControl,
9 TextInput,
10} from '@contentful/f36-components';
11import { css } from 'emotion';
12import { /* useCMA, */ useSDK } from '@contentful/react-apps-toolkit';
13
14export interface AppInstallationParameters {
15 wordsPerMinute?: number;
16}
17
18const ConfigScreen = () => {
19 const [parameters, setParameters] = useState<AppInstallationParameters>({
20 wordsPerMinute: 200,
21 });
22 const sdk = useSDK<ConfigAppSDK>();
23 /*
24 To use the cma, inject it as follows.
25 If it is not needed, you can remove the next line.
26 */
27 // const cma = useCMA();
28
29 const onConfigure = useCallback(async () => {
30 // This method will be called when a user clicks on "Install"
31 // or "Save" in the configuration screen.
32 // for more details see https://www.contentful.com/extensibility/ui-extensions/sdk-reference/#register-an-app-configuration-hook
33
34 // Get current the state of EditorInterface and other entities
35 // related to this app installation
36 const currentState = await sdk.app.getCurrentState();
37
38 return {
39 // Parameters to be persisted as the app configuration.
40 parameters,
41 // In case you don't want to submit any update to app
42 // locations, you can just pass the currentState as is
43 targetState: currentState,
44 };
45 }, [parameters, sdk]);
46
47 useEffect(() => {
48 // `onConfigure` allows to configure a callback to be
49 // invoked when a user attempts to install the app or update
50 // its configuration.
51 sdk.app.onConfigure(() => onConfigure());
52 }, [sdk, onConfigure]);
53
54 useEffect(() => {
55 (async () => {
56 // Get current parameters of the app.
57 // If the app is not installed yet, `parameters` will be `null`.
58 const currentParameters: AppInstallationParameters | null =
59 await sdk.app.getParameters();
60
61 if (currentParameters) {
62 setParameters(currentParameters);
63 }
64
65 // Once preparation has finished, call `setReady` to hide
66 // the loading screen and present the app to a user.
67 sdk.app.setReady();
68 })();
69 }, [sdk]);
70
71 return (
72 <Flex
73 flexDirection="column"
74 className={css({ margin: '80px', maxWidth: '800px' })}
75 >
76 <Form>
77 <Heading>Blog Post Metrics Configuration</Heading>
78 <Paragraph>
79 This app displays real-time blog post metrics in the sidebar,
80 including word count and estimated reading time. Configure the reading
81 speed below to match your target audience.
82 </Paragraph>
83 <FormControl>
84 <FormControl.Label isRequired>Words per minute</FormControl.Label>
85 <TextInput
86 type="number"
87 value={String(parameters.wordsPerMinute || 200)}
88 onChange={(e) =>
89 setParameters({
90 ...parameters,
91 wordsPerMinute: parseInt(e.target.value) || 200,
92 })
93 }
94 />
95 <FormControl.HelpText>
96 Average reading speed used to calculate estimated reading time.
97 Default is 200 words per minute (general audience). Use 250-300 for
98 faster readers or simpler content.
99 </FormControl.HelpText>
100 </FormControl>
101 </Form>
102 </Flex>
103 );
104};
105
106export default ConfigScreen;

Sidebar

1import { useEffect, useState } from 'react';
2import { Note, List } from '@contentful/f36-components';
3import { SidebarAppSDK } from '@contentful/app-sdk';
4import { useSDK } from '@contentful/react-apps-toolkit';
5import { AppInstallationParameters } from './ConfigScreen';
6
7const Sidebar = () => {
8 const sdk = useSDK<SidebarAppSDK>();
9 const [bodyContent, setBodyContent] = useState<string>(
10 sdk.entry.fields.body.getValue() || ''
11 );
12
13 const parameters = sdk.parameters.installation as AppInstallationParameters;
14 const wordsPerMinute = parameters.wordsPerMinute || 200;
15
16 useEffect(() => {
17 // Set up listener for field changes
18 const unsubscribe = sdk.entry.fields.body.onValueChanged(
19 (value: string) => {
20 setBodyContent(value || '');
21 }
22 );
23
24 // Cleanup listener on unmount
25 return () => {
26 unsubscribe();
27 };
28 }, [sdk.entry.fields.body]);
29
30 const calculateMetrics = (content: string) => {
31 const wordCount = content
32 .trim()
33 .split(/\s+/)
34 .filter((word) => word.length > 0).length;
35 const readingTime = Math.ceil(wordCount / wordsPerMinute);
36
37 return {
38 wordCount,
39 readingTime: `${readingTime} min read`,
40 };
41 };
42
43 const metrics = calculateMetrics(bodyContent);
44
45 return (
46 <Info title="Blog Post Metrics">
47 <List>
48 <List.Item>Word Count: {metrics.wordCount} words</List.Item>
49 <List.Item>Reading Time: {metrics.readingTime}</List.Item>
50 </List>
51 </Info>
52 );
53};
54
55export 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.

Bug video

Prompt the AI agent with:

1There'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.
2I think we should remove the 200 as a fallback value in the input.
3Add a validation that checks that the input value is a number in the onConfigure method.
4Lastly, the config screen is not centered, make sure it's centered.

This yields the final corrected version:

1import { useCallback, useState, useEffect } from 'react';
2import { ConfigAppSDK } from '@contentful/app-sdk';
3import {
4 Heading,
5 Form,
6 Paragraph,
7 Flex,
8 FormControl,
9 TextInput,
10} from '@contentful/f36-components';
11import { css } from 'emotion';
12import { /* useCMA, */ useSDK } from '@contentful/react-apps-toolkit';
13
14export interface AppInstallationParameters {
15 wordsPerMinute?: number;
16}
17
18const ConfigScreen = () => {
19 const [parameters, setParameters] = useState<AppInstallationParameters>({
20 wordsPerMinute: 200,
21 });
22 const sdk = useSDK<ConfigAppSDK>();
23 /*
24 To use the cma, inject it as follows.
25 If it is not needed, you can remove the next line.
26 */
27 // const cma = useCMA();
28
29 const onConfigure = useCallback(async () => {
30 // This method will be called when a user clicks on "Install"
31 // or "Save" in the configuration screen.
32 // for more details see https://www.contentful.com/extensibility/ui-extensions/sdk-reference/#register-an-app-configuration-hook
33
34 // Validate that wordsPerMinute is a valid number
35 const wordsPerMinute = parameters.wordsPerMinute;
36 if (!wordsPerMinute || wordsPerMinute <= 0) {
37 sdk.notifier.error('Words per minute must be a valid positive number');
38 return false;
39 }
40
41 // Get current the state of EditorInterface and other entities
42 // related to this app installation
43 const currentState = await sdk.app.getCurrentState();
44
45 return {
46 // Parameters to be persisted as the app configuration.
47 parameters,
48 // In case you don't want to submit any update to app
49 // locations, you can just pass the currentState as is
50 targetState: currentState,
51 };
52 }, [parameters, sdk]);
53
54 useEffect(() => {
55 // `onConfigure` allows to configure a callback to be
56 // invoked when a user attempts to install the app or update
57 // its configuration.
58 sdk.app.onConfigure(() => onConfigure());
59 }, [sdk, onConfigure]);
60
61 useEffect(() => {
62 (async () => {
63 // Get current parameters of the app.
64 // If the app is not installed yet, `parameters` will be `null`.
65 const currentParameters: AppInstallationParameters | null =
66 await sdk.app.getParameters();
67
68 if (currentParameters) {
69 setParameters(currentParameters);
70 }
71
72 // Once preparation has finished, call `setReady` to hide
73 // the loading screen and present the app to a user.
74 sdk.app.setReady();
75 })();
76 }, [sdk]);
77
78 return (
79 <Flex
80 flexDirection="column"
81 className={css({ margin: '80px auto', maxWidth: '800px' })}
82 >
83 <Form>
84 <Heading>Blog Post Metrics Configuration</Heading>
85 <Paragraph>
86 This app displays real-time blog post metrics in the sidebar,
87 including word count and estimated reading time. Configure the reading
88 speed below to match your target audience.
89 </Paragraph>
90 <FormControl>
91 <FormControl.Label isRequired>Words per minute</FormControl.Label>
92 <TextInput
93 type="number"
94 value={
95 parameters.wordsPerMinute !== undefined
96 ? String(parameters.wordsPerMinute)
97 : ''
98 }
99 onChange={(e) => {
100 const value = e.target.value;
101 setParameters({
102 ...parameters,
103 wordsPerMinute: value === '' ? undefined : parseInt(value),
104 });
105 }}
106 />
107 <FormControl.HelpText>
108 Average reading speed used to calculate estimated reading time.
109 Default is 200 words per minute (general audience). Use 250-300 for
110 faster readers or simpler content.
111 </FormControl.HelpText>
112 </FormControl>
113 </Form>
114 </Flex>
115 );
116};
117
118export default ConfigScreen;

The final result looks like this:

Demo video

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.