Skip to content

Your First Extension

tombogle edited this page Aug 26, 2025 · 50 revisions

Introduction

This template is designed to help you create Platform.Bible extensions.

In this tutorial, you will create a simple "Hello World" extension to learn the basics of how extensions work.

Template Contents

The paranext-extension-template includes only the essential components needed to run an extension. This section describes these minimal parts. For a full breakdown of an extension’s structure, see Extension Anatomy.

The main directories are:

/src

/src contains the main entry file and a directory for types that contains the .d.ts file for your extension.

Inside main.ts the PAPI backend service is imported, and from it, the logger service to output logs to the console. Each extension must contain a main entry file with activate() and deactivate() functions. In this template, these functions simply log a message to the console.

The types file declares a module that can be populated with type declarations describing the APIs your extension provides to PAPI.

/webpack

The /webpack directory contains the Webpack configuration files. The only modifications required in this directory are in webpack.config.main.ts, which is described in the next section.

Prerequisites - getting your environment set up

Before you begin to follow the steps to create a "Hello World" extension, if you have not already done so, do the following:

  1. Follow the To install instructions to set up an environment where you can build and run this Hello World extension inside Platform.Bible.

  2. Navigate to the paranext-extension-template repo.

  3. Select the “Use this template” option. It will ask you to create a new repo for your extension.

  4. Clone your extension repo into the same parent directory as paranext-core. (This means you won’t have to reconfigure paths to paranext-core.)

  5. If you want to keep your "Hello World" extension updated as Platform.Bible evolves, follow the one-time setup instructions—including fetching from the template and performing an initial merge—in To update this extension from the template to enable your extension to receive updates from the template. If this is just a throwaway exercise, you can skip this step—but since it’s quick and easy, it’s best to set it up if you’re unsure.

Creating a "Hello World" Extension

In this section, you’ll turn the template into a “Hello World” extension.

Details

The first step in adapting the template is to replace instances of paranext-extension-template with your extension’s details. Below is a list of all the files that need to be edited.

Most of these changes can be done using search and replace, except for the extension manifest files—which must be edited manually—and the types file, which must be renamed.

Files to be edited:

  • README.md
  • package-lock.json
  • package.json
  • manifest.json
  • src/types/paranext-extension-template.d.ts (the file name and contents)
  • src/main.ts (see details in the WebView provider section below)
Search and replace:
  • Search for: paranext-extension-template Replace with: hello-world Exclude: tailwind.css and the README.md file (as well as all the things excluded with the Use Exclude Settings and Ignore Files option, which is probably selected by default)
Manual changes:
  • Rename src/types/paranext-extension-template.d.ts to hello-world.d.ts

    Also change its name in the line in the Summary section of the README.md to avoid confusion later.

  • Edit the extension manifest files, package.json and manifest.json, to set the name, publisher, author, etc.

    The name in manifest.json must be helloWorld (lowerCamelCase).

Finally, save your changes and run npm start to ensure that Platform.Bible still opens as expected and does not encounter errors when loading the new extension. (Since the extension doesn’t really do much yet, just search in terminal for the message: Extension template is activating! to confirm that it loaded properly.)

WebViews

The next step is to add a WebView to see the extension inside of Platform.Bible. In order to add a WebView, you’ll need:

  • Content to render (React or HTML)
  • Any styles needed to display the content properly
  • A WebView provider (to define and return the WebView)
  • Code inside the activate() function to register the WebView and optionally open it immediately

WebView content

Platform.Bible supports both React and HTML WebViews. React WebViews are the most powerful and will likely account for the majority of WebViews in extensions. However, for straightforward scenarios with limited JavaScript and formatting needs—particularly when reusing existing HTML content—HTML WebViews can be sufficient. This tutorial covers both, but feel free to focus on only one or the other if you already anticipate implementing just one type.

URL-based WebViews (using papi-extension: or https: URLs) are also supported but are not covered here.

React WebView

First create a stylesheet that your React WebViews can use: src/hello-world.scss. For the purpose of this Hello World example, just add a single line to use the Tailwind classes provided by Platform.Bible (styled according to the host application’s theme):

@use 'tailwind';

NOTE: For details on adding custom styles in your own extensions, see Styling WebViews.

To create the React WebView, create a new file src/hello-world.web-view.tsx. In it, assign globalThis.webViewComponent to your function component that returns the JSX that you want to show up in your extension. The code below uses the <Button> component from the platform-bible-react library, which helps the extension match the look and feel of the host application.

// src/hello-world.web-view.tsx
import { Button } from "platform-bible-react";

globalThis.webViewComponent = function HelloWorld() {
	return (
    <div className="tw-flex tw-flex-col tw-gap-4 tw-p-6">
      <div className="pr-twp tw-text-2xl tw-font-semibold tw-tracking-tight">
        Hello World <span className="tw-text-muted-foreground tw-font-normal">React</span>
      </div>
      <Button className="pr-twp tw-w-fit tw-min-w-[8rem] tw-gap-2 tw-whitespace-nowrap">Our first button</Button>
    </div>
	);
};
HTML WebView

You can also create an HTML WebView. Create the new HTML file src/hello-world-html.web-view.html with:

<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello World HTML WebView</title>
    <style>
      /* The extension template does not yet support bundling Tailwind into HTML WebViews, so it is
       not available. This is a simple approximation for illustrative purposes. */
      body {
        padding: 1rem;
        font-family: sans-serif;
      }

      .title {
        font-size: 1.25rem;
        margin-bottom: 1rem;
      }

      button {
        background-color: hsl(222.2 47.4% 11.2%);
        color: white;
        padding: 0.5rem 1rem;
        border: none;
        border-radius: 0.375rem;
        cursor: pointer;
      }

      button:hover {
        background-color: hsl(210, 25%, 15%);
      }

      .output {
        margin-top: 1rem;
        font-weight: 600;
      }
    </style>
  </head>
  <body>
    <div class="title">Hello World HTML WebView</div>
    <button id="hello-button">
      Click Me
    </button>
    <div id="output" class="output"></div>

    <script>
      document.getElementById("hello-button").addEventListener("click", () => {
        document.getElementById("output").textContent = "Hello from the HTML WebView!";
      });
    </script>
  </body>
</html>

WebView provider

In this section you’ll create both a React WebView provider and an HTML WebView provider, corresponding to the two WebViews you just added, so Platform.Bible knows how to render and present the WebViews when they are requested.

Each WebView provider checks that the saved WebView definition matches the type of WebView it is responsible for. If the types do not match, it throws an error. If they do match, it returns a new definition that combines the saved information with the WebView’s title, component, and styles. Platform.Bible then uses this definition to render the WebView in the application.

React WebView provider

In src/main.ts import the required types, component, and styles, then define the provider object.

// src/main.ts
import type {
	IWebViewProvider,
	SavedWebViewDefinition,
	WebViewDefinition,
} from '@papi/core';
import helloWorld from "./hello-world.web-view?inline";
import helloWorldReactStyles from './hello-world.scss?inline';

// Although not (currently) a requirement, beginning with the lowerCamelCase version of the
// extension, followed by a period, is a good idea to ensure uniqueness
const reactWebViewType = "helloWorld.react";

/**
 * Simple WebView provider that provides React WebViews when PAPI requests them
 */
const reactWebViewProvider: IWebViewProvider = {
	async getWebView(
		savedWebView: SavedWebViewDefinition
	): Promise<WebViewDefinition | undefined> {
		if (savedWebView.webViewType !== reactWebViewType)
			throw new Error(
				`${reactWebViewType} provider received request to provide a ${savedWebView.webViewType} WebView`
			);
		return {
			...savedWebView,
			title: "Hello World React",
			content: helloWorld,
			styles: helloWorldReactStyles,
		};
	},
};
HTML WebView provider

Like the React WebView and stylesheet, the content for an HTML WebView is imported as inline. An HTML WebView provider is very similar to a React WebView. The notable differences are:

  • The webViewType constant on the WebViewDefinition needs to be set to a unique value.
  • The returned object needs to have contentType set to 'html'.
  • HTML WebViews do not specify styles.
// src/main.ts
import helloWorldHtml from './hello-world-html.web-view.html?inline';

const htmlWebViewType = "helloWorld.html";

/**
 * Simple WebView provider that provides HTML WebViews when PAPI requests them
 */
const htmlWebViewProvider: IWebViewProvider = {
	async getWebView(
		savedWebView: SavedWebViewDefinition
	): Promise<WebViewDefinition | undefined> {
		if (savedWebView.webViewType !== htmlWebViewType)
			throw new Error(
				`${reactWebViewType} provider received request to provide a ${savedWebView.webViewType} web view`
			);
		return {
			...savedWebView,
			title: "Hello World HTML",
			contentType: 'html',
			content: helloWorldHtml,
		};
	},
};

Register, get, and await

To integrate with the PAPI backend service, add two new imports:

  • The PAPI backend service itself
  • The ExecutionActivationContext object that is passed to the activate() function.

    The activate() function should already be defined in the src/main.ts file, but it will require some modifications.

Inside activate(), use the PAPI WebView provider service to register the provider you’ve just created, specifying the type of WebView. Then, use the WebView service to either create a new WebView or get an existing one. Finally, append the awaited promises to the list of registrations at the end, ensuring that they don’t block the execution of other processes.

// src/main.ts
import papi, { logger } from "@papi/backend";
import type {
  ExecutionActivationContext,
  IWebViewProvider,
  SavedWebViewDefinition,
  WebViewDefinition,
} from '@papi/core';

export async function activate(context: ExecutionActivationContext): Promise<void> {
	logger.info("Hello World is activating!");

	const reactWebViewProviderPromise = papi.webViewProviders.registerWebViewProvider(
		reactWebViewType,
		reactWebViewProvider,
	);

	const htmlWebViewProviderPromise = papi.webViewProviders.registerWebViewProvider(
		htmlWebViewType,
		htmlWebViewProvider,
	);

	// Create WebViews or open an existing webview if one already exists for this type
	// Note: here, the use of `existingId: '?'` tells the PAPI not to create a new webview
	// if one already exists. The webview that already exists could have been created by anyone
	// anywhere; it just has to match `webViewType`. See `hello-someone.ts` for an example of keeping
	// an existing webview that was specifically created by `hello-someone`.
	// In real extensions, WebViews are often opened in response to user actions, such as selecting a
	// menu item, rather than automatically during activation. This tutorial opens the WebView
	// directly to simplify testing and demonstration.
	papi.webViews.openWebView(reactWebViewType, undefined, { existingId: "?" });
	papi.webViews.openWebView(htmlWebViewType, undefined, { existingId: "?" });

	context.registrations.add(await reactWebViewProviderPromise, await htmlWebViewProviderPromise);

	logger.info("Hello World is finished activating!");
}

Run and verify the WebViews in your new extension

In a terminal window, run npm start to run Platform.Bible with your extension. When it comes up you should see two new tabs:

  • Hello World React should show a WebView with the message Hello World React and a <button> with the text Our first button.
  • Hello World HTML should show a WebView with the message Hello World HTML WebView and a <button> with the text Click Me. (This button is already active, so clicking it should display the line Hello from the HTML WebView!.)

PAPI Commands and Using Events

Next you’ll make the button in the React WebView do something using commands. You’ll create a shared counter, managed by the extension, that tracks the total number of times the user clicks the button in any instance of the WebView. Then you’ll hook up the button to run a command that increments this shared counter and triggers an event so every WebView is notified when the count changes.

This pattern is useful whenever an extension needs to keep state consistent across multiple WebViews, potentially including WebViews created by other extensions.

Define the event and command types for your extension

To support communication between your extension and Platform.Bible (and potentially other extensions), you’ll define the relevant types in the src\types\hello-world.d.ts file. This includes:

  • an event (DoStuffEvent) that tracks how many times a certain command has been run.
  • A command (helloWorld.doStuff) that tells Platform what parameters the command expects and what it returns.

First, declare the event inside the existing hello-world module. The event should expose a single count field representing the number of times the command has been triggered.

Finally, add a papi-shared-types module, declaring a CommandHandlers interface to define the command’s function signature—so PAPI and other extensions can interact with it. This command will accept a string (the input message) and return an object containing a string response and a number occurrence (i.e., the current count of how many times the button has been clicked).

// src\types\hello-world.d.ts
declare module "hello-world" {
	/** Network event that informs subscribers when the command `helloWorld.doStuff` is run */
	export type DoStuffEvent = {
		/** How many times the extension has run the command `helloWorld.doStuff` */
		count: number;
	};
}

declare module "papi-shared-types" {
	export interface CommandHandlers {
		"helloWorld.doStuff": (message: string) => {
			response: string;
			occurrence: number;
		};
	}
}

Register a command and emit events in activate function

In the activate() (defined in main.ts), you’ll set up everything needed for the new helloWorld.doStuff command:

  • Create a network event emitter (using the network service and the DoStuffEvent type) to broadcast updates to subscribers.
  • Register the command with the command service so it can be called from other parts of the app or other extensions.
  • Add resources to context.registrations so they will be automatically cleaned up when the extension is deactivated.
// src\main.ts
import type { DoStuffEvent } from 'hello-world';
...
	
export async function activate(context: ExecutionActivationContext) {
    ...

	// Emitter to tell subscribers how many times we have done stuff
	const onDoStuffEmitter = papi.network.createNetworkEventEmitter<DoStuffEvent>(
		'helloWorld.doStuff',
	);

	let doStuffCount = 0;
	const doStuffCommandPromise = papi.commands.registerCommand(
	'helloWorld.doStuff',
	(message: string) => {
		doStuffCount += 1;
		// Inform subscribers of the update
		onDoStuffEmitter.emit({ count: doStuffCount });
	
		// Respond to the sender of the command with the news
		return {
			response: `The template did stuff ${doStuffCount} times! ${message}`,
			occurrence: doStuffCount,
		};
	},
	);

	context.registrations.add(
		await reactWebViewProviderPromise,
		await htmlWebViewProviderPromise,
		onDoStuffEmitter,
		await doStuffCommandPromise,
	);
    ...
}

Call commands and handle events from a WebView

In the React WebView file, you’ll connect the button click to the backend command and keep the UI in sync with the latest count.

To accomplish this, do the following:

  • Add imports
    • PAPI frontend service for calling commands and subscribing to events
    • The DoStuffEvent type
    • React and PAPI hooks for state and event handling
  • Create state to track the number of button clicks.
  • **Send the command using sendCommand() when button is clicked.
  • Log the response (and the time taken) using the logger service.
  • Listen for updates with the useEvent hook so the click counter updates whenever the backend emits the event.
// src\hello-world.web-view.tsx
import papi, { logger } from "@papi/frontend";
import { Button, useEvent } from 'platform-bible-react';
import type { DoStuffEvent } from 'hello-world';
import { useCallback, useState } from 'react';

globalThis.webViewComponent = function HelloWorld() {
	const [clicks, setClicks] = useState(0);

	useEvent<DoStuffEvent>(
		papi.network.getNetworkEvent('helloWorld.doStuff'),
		useCallback(({ count }) => setClicks(count), [])
	);

	return (
		<div className="tw-flex tw-flex-col tw-gap-4 tw-p-6">
			<div className="pr-twp tw-text-2xl tw-font-semibold tw-tracking-tight">
				Hello World <span className="tw-text-muted-foreground tw-font-normal">React</span>
			</div>
			<Button
				className="pr-twp tw-w-fit tw-min-w-[8rem] tw-gap-2 tw-whitespace-nowrap"
				onClick={async () => {
					const start = performance.now();
					const result = await papi.commands.sendCommand(
						'helloWorld.doStuff',
						'Hello World React Component',
					);
					logger.info(
						`command:helloWorld.doStuff '${result.response}' took ${performance.now() - start} ms`,
					);
				}}
			>
				Hi {clicks}
			</Button>
		</div>
	);
};

Run and verify the click counter works

Use npm start to start Platform.Bible with the your updated extension. You should now see a button that says 'Hi' and displays the number of clicks that is calculated using the command and event you created.

Data provider

Finally, you’ll add and use a data provider class in your extension. Implementing a DataProvider lets your extension (and others) call useData to read and write the data your extension manages.

Declare the types of data the provider will use and share

Inside the types file (src\types\hello-world.d.ts), declare the types of data the provider will use and share. Your Hello World data provider will have three data types:

  • Verse – a portion of Scripture at a given reference

    Attempting to set the text through this type will fail unless isEdited: true is explicitly provided.

  • VerseTextOverride – the (potentially overridden) text of a verse

    Retrieving text through this type always gives the same value as Verse. Setting text through this type automatically marks the verse as edited, clearly indicating to the user that the content no longer matches the originally published Scripture.

  • Chapter – a whole chapter of Scripture identified by book name and chapter number

    This data is read-only.

Note: The snippet below shows only the new types and interface being added, with the surrounding code included solely to indicate where they belong.

// src\types\hello-world.d.ts
declare module "hello-world" {
	import type { DataProviderDataType, IDataProvider } from '@papi/core';

	export type ExtensionVerseSetData =
		| string
		| { text: string; isEdited: boolean };

	export type ExtensionVerseDataTypes = {
		Verse: DataProviderDataType<
			string,
			string | undefined,
			ExtensionVerseSetData
		>;
		VerseTextOverride: DataProviderDataType<string, string | undefined, string>;
		Chapter: DataProviderDataType<
			[book: string, chapter: number],
			string | undefined,
			never
		>;
	};

	export type ExtensionVerseDataProvider =
		IDataProvider<ExtensionVerseDataTypes>;
}

declare module "papi-shared-types" {
	import type { ExtensionVerseDataProvider } from "hello-world";

	export interface DataProviders {
		"helloWorld.quickVerse": ExtensionVerseDataProvider;
	}
}

Create the data provider

In main.ts, but outside of the activate() function, create your data provider class or object. The example below shows a data provider engine class that offers easy access to Scripture data from another data provider. (There are pros and cons to using a class versus an object for a data provider engine.)

First, add or update the imports as shown. Then create the data provider engine, declaring any needed variables, and add a constructor.

// src\main.ts
import { VerseRef } from '@sillsdev/scripture';
import papi, { DataProviderEngine, logger } from '@papi/backend'
import type { DataProviderUpdateInstructions, IDataProviderEngine } from '@papi/core';
import type {
  DoStuffEvent,
  ExtensionVerseDataTypes,
  ExtensionVerseSetData,
} from 'hello-world';

class QuickVerseDataProviderEngine
  extends DataProviderEngine<ExtensionVerseDataTypes>
  implements IDataProviderEngine<ExtensionVerseDataTypes>
{
  verses: { [scrRef: string]: { text: string; isChanged?: boolean } } = {};

  /** Latest updated verse reference */
  latestVerseRef = 'JHN 11:35';

  usfmDataProviderPromise = papi.projectDataProviders.get(
    'platformScripture.USFM_Verse',
    '32664dc3288a28df2e2bb75ded887fc8f17a15fb',
  );

  /** Number of times any verse has been modified by a user this session */
  editCount = 0;

  /** @param editWarning string to prefix edited data */
  constructor(public editWarning: string) {
    // `DataProviderEngine`'s constructor currently does nothing, but TypeScript requires this call.
    super();

    this.editWarning = this.editWarning ?? 'editCount =';
  }

  ...
}

Adding helper methods

At this point, you might notice two things in your editor:

  • An error stating that IDataProviderEngine<ExtensionVerseDataTypes> is not implemented.
  • A warning that the new import ExtensionVerseSetData is not yet used.

That’s expected—you haven’t added the get and set functions yet. You’ll implement those methods in a moment, but first you’ll add two helper methods, as shown in the following code snippet:

  • setInternal() – An internal version of the set method that does not send updates. It will be used by setVerse() and setEdited() to avoid broadcasting unnecessary changes.

Note: A method whose name starts with set is normally treated by PAPI as a data type method, meaning a matching getInternal method would be expected. Because that isn’t the case here, it should be decorated with @papi.dataProviders.decorators.ignore so PAPI skips it. Alternatively, you could simply give it a name that doesn’t start with set, such as _setInternal or internalSet.

  • #getSelector() – A private method (note the #) that cannot be called over the network.
// src\main.ts
  @papi.dataProviders.decorators.ignore
  async setInternal(
    selector: string,
    data: ExtensionVerseSetData,
  ): Promise<DataProviderUpdateInstructions<ExtensionVerseDataTypes>> {
    // Just get notifications of updates with the 'notify' selector. Nothing to change
    if (selector === 'notify') return false;

    // You can't change scripture from just a string. You have to pass an object to make intentions explicit.
    if (typeof data === 'string' || data instanceof String || !data.isEdited) return false;

    // If there is no actual change in the verse text, don't update
    if (data.text === this.verses[this.#getSelector(selector)].text) return false;

    // Update the verse text, track the latest change, and send an update
    this.verses[this.#getSelector(selector)] = {
      text: data.text,
      isChanged: true,
    };
    if (selector !== 'latest') this.latestVerseRef = this.#getSelector(selector);
    this.editCount += 1;
    // Update all data types -- Verse and VerseTextOverride in this case
    return '*';
  }

  #getSelector(selector: string) {
    const selectorL = selector.toLowerCase().trim();
    return selectorL === 'latest' ? this.latestVerseRef : selectorL;
  }

Implementing the getters and setters

For each data type you’ve declared, the data provider engine needs a get<data_type> and a set<data_type>.

Verse get and set
// src\main.ts
  async setVerse(verseRef: string, data: ExtensionVerseSetData) {
    return this.setInternal(verseRef, data);
  }

  async getVerse(verseRef: string) {
    // Just get notifications of updates with the 'notify' selector
    if (verseRef === 'notify') return undefined;
    const selector = this.#getSelector(verseRef);

    // Look up the cached data first.
    let responseVerse = this.verses[selector];

    // If the verse is not already cached, cache it.
    if (!responseVerse) {
      // Fetch the verse, cache it, and return it.
      try {
        const usfmDataProvider = await this.usfmDataProviderPromise;
        if (!usfmDataProvider) throw Error('Unable to get USFM data provider');
        const verseData = usfmDataProvider.getVerseUSFM(new VerseRef(selector));
        responseVerse = { text: (await verseData) ?? `${selector} not found` };
        // Cache the verse text, track the latest cached verse, and send an update
        this.verses[selector] = responseVerse;
        this.latestVerseRef = selector;
        this.notifyUpdate();
      } catch (e) {
        responseVerse = {
          text: `Failed to fetch ${selector} from USFM data provider! Reason: ${e}`,
        };
      }
    }

    if (responseVerse.isChanged) {
      // Remove any previous edit warning from the beginning of the text so they don't stack
      responseVerse.text = responseVerse.text.replace(/^\[.* \d*\] /, '');
      return `[${this.editWarning} ${this.editCount}] ${responseVerse.text}`;
    }
    return responseVerse.text;
  }
VerseTextOverride get and set
// src\main.ts
  async setVerseTextOverride(verseRef: string, verseText: string) {
    return this.setInternal(verseRef, { text: verseText, isEdited: true });
  }

  async getVerseTextOverride(verseRef: string) {
    return this.getVerse(verseRef);
  }
Chapter get and set
// src\main.ts
  // Does nothing, so no need to use `this`
  // eslint-disable-next-line @typescript-eslint/class-methods-use-this
  async setChapter() {
    // We are not supporting setting chapters now, so don't update anything
    return false;
  }

  async getChapter(chapterInfo: [book: string, chapter: number]) {
    const [book, chapter] = chapterInfo;
    return this.getVerse(`${book} ${chapter}`);
  }

Activate data provider

Inside the activate() function, create an instance of the data provider engine and send it a starter string. Then register this engine with the dataProviders service using a unique ID. Finally, to avoid blocking other activation tasks, await the registration promise at the end of activate().

// src\main.ts
const engine = new QuickVerseDataProviderEngine("editCount =");

const quickVerseDataProviderPromise = papi.dataProviders.registerEngine(
	"helloWorld.quickVerse",
	engine
);

context.registrations.add(
  // previous registrations...
  await quickVerseDataProviderPromise
);

Use data provider in WebView

Now that you are ready to use the new helloWorld.quickVerse data provider, you’ll discard most of the original implementation of the React WebView (with the Button that triggered the DoStuffEvent event) and instead have it display the text of the latest verse. (Unless you do some additional work to make this WebView -- or another one -- that can change the verse reference, this will always be the default verse that is set in main.ts: JHN 11:35.)

Change the imports in hello-world.web-view.tsx to import the useData and useDataProvider hooks from @papi/frontend/react. Declare a const for the data provider using the useDataProvider hook. Next, declare a variable that will get the verse data from the provider using the useData hook. Finally, display the data in a <div>.

Note: When fetching data from a data provider with useData, you don’t have to use useDataProvider unless you need direct access to the provider instance itself. Instead of passing the provider object to useData (as shown in thi example), you can simply pass the data provider ID string.

// src\hello-world.web-view.tsx
import { useData, useDataProvider } from '@papi/frontend/react';

globalThis.webViewComponent = function HelloWorld() {
  const extensionVerseDataProvider = useDataProvider('helloWorld.quickVerse');

  const [latestExtensionVerseText] = useData<'helloWorld.quickVerse'>(
    extensionVerseDataProvider,
  ).Verse('latest', 'Loading latest Scripture text from Hello World...');

  return (
    <div className="tw-flex tw-flex-col tw-gap-4 tw-p-6">
      <div className="pr-twp tw-text-2xl tw-font-semibold tw-tracking-tight">
        Hello World <span className="tw-text-muted-foreground tw-font-normal">React</span>
      </div>
      <div>
        {latestExtensionVerseText}
      </div>
    </div>
  );
};

Dealing with PlatformError

If you run Platform.Bible at this point, it should display the text of John 11:35 as expected. However, you might notice the error in the editor warning you that latestExtensionVerseText might be a PlatformError object. If PAPI returns an error instead of a string (or undefined), React won’t be able to render it directly as a child in JSX. To fix this, add imports for getErrorMessage and isPlatformError from platform-bible-utils and then change the JSX to handle the case where latestExtensionVerseText is a PlatformError.

// src\hello-world.web-view.tsx
import { useData, useDataProvider } from '@papi/frontend/react';
import { getErrorMessage, isPlatformError } from 'platform-bible-utils';

globalThis.webViewComponent = function HelloWorld() {
  const extensionVerseDataProvider = useDataProvider('helloWorld.quickVerse');

  const [latestExtensionVerseText] = useData<'helloWorld.quickVerse'>(
    extensionVerseDataProvider,
  ).Verse('latest', 'Loading latest Scripture text from Hello World...');

  return (
    <div className="tw-flex tw-flex-col tw-gap-4 tw-p-6">
      <div className="pr-twp tw-text-2xl tw-font-semibold tw-tracking-tight">
        Hello World <span className="tw-text-muted-foreground tw-font-normal">React</span>
      </div>
      <div>
        {isPlatformError(latestExtensionVerseText)
          ? getErrorMessage(latestExtensionVerseText)
          : latestExtensionVerseText}
      </div>
    </div>
  );
};

Run and verify the verse is displayed correctly

Use npm start again. You should now see the WebView displaying verse data from the data provider!

Next steps

This example covers just the basics: displaying a single verse using the Verse data type. However, your extension already includes other data types like VerseTextOverride and Chapter that enable more complex interactions—such as changing verses (and marking them as edited) or retrieving entire chapters.

We encourage you to explore these additional data types and their corresponding get/set methods to expand your extension’s capabilities. For instance, you might add UI elements to:

  • Change the current verse or chapter being displayed
  • Edit verse text freely using the VerseTextOverride data type, observing how changes are flagged
  • Fetch and display an entire chapter instead of a single verse

Experimenting with these features will deepen your understanding of how Platform.Bible’s data provider system works and how to build richer, more interactive extensions.

Further Reading

Clone this wiki locally