diff --git a/.changeset/mighty-stingrays-pay.md b/.changeset/mighty-stingrays-pay.md
new file mode 100644
index 000000000..f1b222387
--- /dev/null
+++ b/.changeset/mighty-stingrays-pay.md
@@ -0,0 +1,5 @@
+---
+"@headstartwp/core": minor
+---
+
+Introduces `SafeHtml` and `HtmlDecoder` components.
diff --git a/docs/documentation/03- Utilities/sanitization.md b/docs/documentation/03- Utilities/sanitization.md
new file mode 100644
index 000000000..53391e990
--- /dev/null
+++ b/docs/documentation/03- Utilities/sanitization.md
@@ -0,0 +1,57 @@
+---
+slug: /utilities/sanitization
+sidebar_label: Escaping & Sanitization
+---
+
+# Escaping & Sanitization
+As you're probably aware, React won't render raw HTML by default. If you want to do so you must use [dangerouslySetInnerHTML](https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html).
+
+This page describes some of the utility functions and components provided by the framework to help with escaping & sanitization when rendering raw markup.
+
+## wpKsesPost
+
+This function sanitizes HTML content with requirements similar to [wp_kses_post](https://developer.wordpress.org/reference/functions/wp_kses_post/). If you are rendering arbitrary HTML markup you should probably run the markup through this function first.
+
+```jsx
+import { wpKsesPost } from '@headstartwp/core';
+
+const markup = { __html: wpKsesPost('
some raw html
') };
+return ;
+```
+
+## stripTags
+
+This function simply strips any html tags from a string. This can be useful in contexts where you don't want any HTML to be rendered.
+
+```jsx
+import { stripTags } from '@headstartwp/core';
+
+return {stripTags('this is a title without a span')}
;
+```
+
+## BlocksRenderer
+
+When using [BlocksRenderer](/learn/gutenberg/rendering-blocks) your markup already goes through `wpKsesPost` so there's nothing else you need to worry about.
+
+## HtmlDecoder
+
+Sometimes you might just want to decode some HTML entities without actually rendering any HTML tags. For this purpose you can use the `HtmlDecoder` component.
+
+```jsx
+import { HtmlDecoder } from '@headstartwp/core/react';
+
+
+
+
+```
+
+## SafeHtml
+
+The `SafeHtml` component provides an easy way to safely render HTML markup. It runs the markup through `wpKsesPost` just like `BlocksRenderer`.
+
+```jsx
+import { SafeHtml } from '@headstartwp/core/react';
+
+
+```
+
diff --git a/packages/core/src/dom/__tests__/stripTags.ts b/packages/core/src/dom/__tests__/stripTags.ts
new file mode 100644
index 000000000..87e9d7052
--- /dev/null
+++ b/packages/core/src/dom/__tests__/stripTags.ts
@@ -0,0 +1,11 @@
+import { stripTags } from '../stripTags';
+
+describe('stripTags', () => {
+ test('it strips tags', () => {
+ expect(stripTags('test
')).toBe('test');
+ expect(stripTags('')).toBe('test test');
+ expect(stripTags('')).toBe(
+ 'alert()hello world',
+ );
+ });
+});
diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts
index 8ba02a4c6..987e65661 100644
--- a/packages/core/src/dom/index.ts
+++ b/packages/core/src/dom/index.ts
@@ -342,3 +342,4 @@ export function isBlockByName(node: DOMNode, name: string) {
}
export * from './wpKsesPost';
+export * from './stripTags';
diff --git a/packages/core/src/dom/stripTags.ts b/packages/core/src/dom/stripTags.ts
new file mode 100644
index 000000000..67e3dbb42
--- /dev/null
+++ b/packages/core/src/dom/stripTags.ts
@@ -0,0 +1,10 @@
+/**
+ * Utility functions to strip any tags
+ *
+ * @param html The html string
+ *
+ * @returns
+ */
+export function stripTags(html) {
+ return html.replace(/(<([^>]+)>)/gi, '');
+}
diff --git a/packages/core/src/react/components/HtmlDecoder.tsx b/packages/core/src/react/components/HtmlDecoder.tsx
new file mode 100644
index 000000000..c9f0996d8
--- /dev/null
+++ b/packages/core/src/react/components/HtmlDecoder.tsx
@@ -0,0 +1,33 @@
+import { FC } from 'react';
+import parse from 'html-react-parser';
+import { stripTags } from '../../dom';
+
+export interface HtmlDecodeProps {
+ /**
+ * The string with html entities to decode
+ *
+ * ```jsx
+ *
+ * ```
+ */
+ html: string;
+}
+
+/**
+ * The `HtmlDecoder` simply decodes html entities
+ *
+ * Any actual html markup gets stripped before decoding html entities. If you need to render HTML use {@link SafeHtml}
+ *
+ * ## Usage
+ *
+ * ```jsx
+ *
+ * ```
+ *
+ * @param props Component properties
+ *
+ * @category React Components
+ */
+export const HtmlDecoder: FC = ({ html }) => {
+ return <>{parse(stripTags(html))}>;
+};
diff --git a/packages/core/src/react/components/SafeHtml.tsx b/packages/core/src/react/components/SafeHtml.tsx
new file mode 100644
index 000000000..76770fa7f
--- /dev/null
+++ b/packages/core/src/react/components/SafeHtml.tsx
@@ -0,0 +1,46 @@
+import { FC } from 'react';
+import parse from 'html-react-parser';
+import type { IWhiteList } from 'xss';
+import { wpKsesPost } from '../../dom';
+
+export interface SafeHtmlProps {
+ /**
+ * The HTML string to be rendered.
+ *
+ * ```jsx
+ *
+ * ```
+ */
+ html: string;
+
+ /**
+ * The allow list for the parser
+ *
+ * ```jsx
+ *
+ * ```
+ */
+ ksesAllowList?: IWhiteList;
+}
+
+/**
+ * The `SafeHtml` component provides an easy way to safely render HTML
+ *
+ * The html prop is sanitized through {@link wpKsesPost} so it's safe for rendering arbitrary html markup.
+ *
+ * ## Usage
+ *
+ * ```jsx
+ *
+ * ```
+ *
+ * @param props Component properties
+ *
+ * @category React Components
+ */
+export const SafeHtml: FC = ({ html, ksesAllowList }) => {
+ return <>{parse(wpKsesPost(html, ksesAllowList))}>;
+};
diff --git a/packages/core/src/react/components/__tests__/HtmlDecoder.tsx b/packages/core/src/react/components/__tests__/HtmlDecoder.tsx
new file mode 100644
index 000000000..02d5bfae8
--- /dev/null
+++ b/packages/core/src/react/components/__tests__/HtmlDecoder.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+import { render } from '@testing-library/react';
+import { HtmlDecoder } from '../HtmlDecoder';
+
+describe('HtmlDecoder', () => {
+ it('decodes entities', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.firstChild).toMatchInlineSnapshot(
+ `Hello world! – foo bar – ‘\\@£#?,’\\[]`,
+ );
+ });
+
+ it('does not render arbitrary markup', () => {
+ const { container } = render();
+ expect(container).toMatchInlineSnapshot(`
+
+ This is a title
+
+ `);
+ });
+});
diff --git a/packages/core/src/react/components/__tests__/SafeHtml.tsx b/packages/core/src/react/components/__tests__/SafeHtml.tsx
new file mode 100644
index 000000000..c5b4b2972
--- /dev/null
+++ b/packages/core/src/react/components/__tests__/SafeHtml.tsx
@@ -0,0 +1,27 @@
+import * as React from 'react';
+import { render } from '@testing-library/react';
+import { SafeHtml } from '../SafeHtml';
+
+describe('SafeHtml', () => {
+ it('renders entities', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.firstChild).toMatchInlineSnapshot(
+ `Hello world! – foo bar – ‘\\@£#?,’\\[]`,
+ );
+ });
+
+ it('renders arbitrary markup', () => {
+ const { container } = render();
+ expect(container).toMatchInlineSnapshot(`
+
+ This is a
+
+ title
+
+
+ `);
+ });
+});
diff --git a/packages/core/src/react/components/index.ts b/packages/core/src/react/components/index.ts
index b1e7b1f15..7e1f33709 100644
--- a/packages/core/src/react/components/index.ts
+++ b/packages/core/src/react/components/index.ts
@@ -1,2 +1,4 @@
export * from './BlocksRenderer';
export * from './Menu';
+export * from './SafeHtml';
+export * from './HtmlDecoder';
diff --git a/projects/wp-nextjs/src/components/PageContent.js b/projects/wp-nextjs/src/components/PageContent.js
index e73308a20..3a35cea6c 100644
--- a/projects/wp-nextjs/src/components/PageContent.js
+++ b/projects/wp-nextjs/src/components/PageContent.js
@@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import { usePost } from '@headstartwp/next';
import dynamic from 'next/dynamic';
+import { HtmlDecoder } from '@headstartwp/core/react';
const Blocks = dynamic(() => import('./Blocks'));
@@ -19,7 +20,9 @@ export const PageContent = ({ params }) => {
return (
<>
- {data.post.title.rendered}
+
+
+
>
);