みなさん、こんにちは!
Salesforce Multi-Framework (React) が GA になり、Salesforce 上で React アプリを動かせるようになりました。アプリケーションランチャーからフルページで起動するアプリとして使えるようになっていたのですが、やはり LWC のように Lightning Experience(LEX)のページに埋め込みたい ——と考える方も多いのではないでしょうか。
それを叶えてくれるのが Salesforce Microfrontend(UI Embedding) です。
- 公式ガイド(本記事の元にした情報): Salesforce Microfrontend: Get Started
⚠️ Microfrontend / UI Embedding は本記事執筆時点で Beta の機能です。今後仕様が変わる可能性があります。検証は Winter ’27 Preview のスクラッチ組織(API 68.0)で行っています。
Salesforce Multi-Framework の React などをはじめ、外部フレームワークで作った UI(UI Bundle)を、lightning-ui-embedding を介して LWC と同じようにレコードページなどへ埋め込めるようにする仕組みです。前回の記事では、React 製の UI Bundle をスクラッチ組織にデプロイして、単体で動かすところまでを試しました。
今回はその続きとして、作った React アプリを Case(ケース)レコードページに埋め込む部分に絞って紹介します。埋め込みには lightning-ui-embedding を使い、LWC 側から React アプリへ「今開いているレコードの ID」を渡す、という連携がキモになります。
📌 本記事の内容は、あくまで 2026年9月時点(Beta)で試した範囲の記録 です。ここで紹介する手順やコードは執筆時点で動作したものであり、正式リリース時には作法や API が変わっている可能性があります。その前提でお読みください。
今回作ったもの
題材として「Case Timeline」という読み取り専用のデモコンポーネントを用意しました。1 件のケースにひもづく複数の履歴 —— メール(EmailMessage)、ToDo(Task)、行動(Event)、ケースコメント、ケース履歴 —— を 1 本の時間軸にまとめて表示する React アプリです。
React アプリ自体の作り込み(React 19 + Vite + TypeScript + Tailwind + shadcn/ui)については前回記事の範囲なので、ここでは深追いしません。「こんな見た目のアプリを作った」という前提で読み進めてください。
全体像
埋め込みの登場人物は次の 4 つです。
1Case レコードページ
2 └─ caseTimelineEmbed (ラッパー LWC / 今回の主役)
3 └─ <lightning-ui-embedding> … iframe を生成
4 └─ React UI Bundle(*.salesforce.app から配信)- React UI Bundle は
*.salesforce.appという専用ドメインから配信され、lightning-ui-embeddingが iframe として画面に埋め込みます。 - レコードページに直接 UI Bundle を置くことはできないので、間に薄い ラッパー LWC(
caseTimelineEmbed)を挟みます。 - このラッパー LWC が「どのアプリを」「どのレコードで」表示するかをアプリ側へ伝えます。
ということで本記事は、ほぼこのラッパー LWC の実装解説です。
ステップ1: 埋め込むアプリの URL を確認して信頼済み URL に登録する
iframe で外部 URL を読み込むため、信頼済み URL(CSP Trusted Site) として登録しておきます。これは公式ガイダンスに沿って [設定] の画面から登録するだけ です。
登録するには、まず埋め込むアプリの正確な URLを知る必要があります。そこで 一度、React アプリを単体でフルに開いてみます。アドレスバーに出る URL を控えます。
あとは [設定] → [セキュリティ] → [信頼済み URL] で、その URL を新規登録し、CSP コンテキストとして frame-src を有効にするだけで��。
ステップ2: ラッパー LWC を作る
いよいよ本題のラッパー LWC です。まずはテ��プレート(HTML)から。<lightning-ui-embedding> に src(アプリの URL)を渡すだけの、とてもシンプルな構成です。
lwc/caseTimelineEmbed/caseTimelineEmbed.html:
1<template>
2 <div style={containerStyle}>
3 <!-- 読み込み失敗時のエラー表示 -->
4 <template lwc:if={errorMessage}>
5 <div role="alert">
6 {errorMessage}
7 </div>
8 </template>
9
10 <lightning-ui-embedding
11 lwc:ref="embedding"
12 src={embeddingUrl}
13 sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
14 shell-title="Case Timeline">
15 </lightning-ui-embedding>
16 </div>
17</template>次に JavaScript です。全体はこれだけです。
lwc/caseTimelineEmbed/caseTimelineEmbed.js:
1import { LightningElement, api } from 'lwc';
2
3// 埋め込むアプリの base URL。末尾は「変化しない」パスにする(後述)。
4const BASE_URL = 'https://enterprise-fun-7452-dev-ed--c.scratch.my.salesforce.app/app/c__CaseTimeline';
5
6// アプリ側と共有するカスタムイベント名。
7// host→app: recordId を渡す / app→host: recordId の送信を要求する。
8const RECORD_ID_EVENT = 'caseRecordId';
9const REQUEST_RECORD_ID_EVENT = 'requestRecordId';
10
11export default class CaseTimelineEmbed extends LightningElement {
12 _recordId;
13 // src はマウント後に変化させない(相対アセットを常にアプリのルート基準で解決させるため)。
14 embeddingUrl = `${BASE_URL}/timeline`;
15 errorMessage;
16
17 // デザイン属性: 初期の高さ。420〜720px にクランプ。
18 @api height = 520;
19
20 @api
21 get recordId() {
22 return this._recordId;
23 }
24 set recordId(value) {
25 if (value === this._recordId) return;
26 this._recordId = value;
27 // レコード遷移時に新しい id を送る(アプリ未接続なら無害に何もしない)。
28 this.sendRecordId();
29 }
30
31 get containerStyle() {
32 const h = Math.min(Math.max(Number(this.height) || 520, 420), 720);
33 return `height:${h}px;`;
34 }
35
36 renderedCallback() {
37 if (this._listenersAttached) return;
38 const embedding = this.refs?.embedding;
39 if (!embedding) return;
40 this._listenersAttached = true;
41 // アプリからの要求(確実)と ready(保険)、どちらでも recordId を送る。
42 embedding.addEventListener(REQUEST_RECORD_ID_EVENT, this.sendRecordId);
43 embedding.addEventListener('sf-embedding.component.ready', this.sendRecordId);
44 embedding.addEventListener('sf-embedding.component.error', this.handleError);
45 }
46
47 disconnectedCallback() {
48 this._listenersAttached = false;
49 }
50
51 // recordId を sf-embedding チャネル経由で app へ送信する。
52 sendRecordId = () => {
53 const embedding = this.refs?.embedding;
54 if (!embedding || !this._recordId) return;
55 embedding.dispatchEvent(
56 new CustomEvent(RECORD_ID_EVENT, { detail: { recordId: this._recordId } })
57 );
58 };
59
60 handleError = (event) => {
61 this.errorMessage =
62 event?.detail?.message ||
63 'Case Timeline アプリの読み込みに失敗しました。時間をおいて再度お試しください。';
64 };
65}そして、レコードページに配置できるようにするメタデータです。
lwc/caseTimelineEmbed/caseTimelineEmbed.js-meta.xml:
1<?xml version="1.0" encoding="UTF-8"?>
2<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
3 <apiVersion>67.0</apiVersion>
4 <isExposed>true</isExposed>
5 <masterLabel>Case Timeline</masterLabel>
6 <targets>
7 <target>lightning__RecordPage</target>
8 </targets>
9 <targetConfigs>
10 <targetConfig targets="lightning__RecordPage">
11 <objects>
12 <object>Case</object>
13 </objects>
14 <property name="height" type="Integer" label="Height (px)"
15 description="Component height in pixels (420–720)."
16 default="520" min="420" max="720" />
17 </targetConfig>
18 </targetConfigs>
19</LightningComponentBundle>※ css ファイルは割愛
ステップ3: recordId をアプリへ渡す
埋め込みで一番のポイントが「LWC が持っているレコード ID を、iframe の中の React アプリへどう渡すか」です。
⚠️ 以降は 本記事執筆時点(Beta)で動いた方法 の記録です。UI Embedding はまだ Beta で、データ受け渡しの作法は今後変わる可能性があります。ここで紹介するのは「公式の仕組みの範囲内で、現時点で確実に動かせた一例」くらいに読んでください。正式リリースでは、もっと素直な方法が用意されるかもしれません。
公式の Exchange Data ガイド では、sf-embedding チャネルを通じてデータをやり取りします。用意されている口は主に 2 つです。
state.props… LWC が流し込む値(テーマ・ロケール・モードなど)。- カスタムイベント …
sf-embeddingチャネル越しにイベントをやり取りする口。
最初は「recordId は state.props に入ってくるのでは?」と思い、そこから取り出そうとしました。ところが実際にレコードページで中身をログに出してみると props は空({}) で、recordId は入っていませんでした。そこでコーディングエージェントとも一緒に調べていった結果、現時点では「カスタムイベントで渡す」のが一番確実だった ——というのが、今回たどり着いた形です。
📝
srcに/timeline/{recordId}のようにレコード ID を載せる方式もあるかもしれませんが、今回は公式ガイドの手法(カスタムイベン���)をベースとしました。
送るタイミングでハマった話(現時点の注意点)
もう 1 つ、送信タイミングでつまずきました。公式ガイドは「ロケールなどのデータは embedding component が ready を報告した後に送る」と案内していて、一見「LWC 側で ready を待って送れば十分」に見えます。ところが今回の構成では、うまくいきませんでした。
1embedding.addEventListener('sf-embedding.component.ready', this.sendRecordId);今回試した限りでは、LWC が ready を受け取って caseRecordId を送るタイミングと、アプリ側でそのイベントを受け取る準備が整うタイミングが、ちょうど前後してしまうことがあるようでした。アプリ側は createViewSDK() の解決を待ってからリスナーを登録するため、LWC からの通知の方が先に届くと、そのイベントを受け取れないことがあったようです。イベントは後から登録したリスナーには届かないため、そのまま取りこぼしてしまう、という状況でした。
※ このあたりは SDK 内部の挙動に関わる部分なので、あくまで今回の環境で観測した範囲からの推測としてお読みください。
そこで、送られてくる順序に左右されないよう、アプリ → LWC へ問い合わせる方式(ハンドシェイク) に切り替えました。アプリは自分のリスナーを登録し終えてから「recordId を送ってください」と LWC へ要求し、LWC がそれに応えて caseRecordId を返します。要求はリスナーの準備が整ってから送るため取りこぼしが起きにくく、今回はこの形で安定しました(ready を契機とした送信も、念のため残してあります)。
- LWC → アプリ:
caseRecordIdイベントで recordId を渡す - アプリ → LWC:
requestRecordIdイベントで送信を要求する(dispatchEventによる app→host は公式にサポート)
LWC 側は、requestRecordId でも ready でも、どちらが来ても recordId を送り返します(前掲の renderedCallback を参照)。
アプリ(React)側の受け取り
受信ロジックはモジュール読み込み時に走る小さなストアにまとめ、app.tsx の先頭(ui-embedding の import 直後)で読み込みます。リスナーを登録したら、その場で requestRecordId を投げるのがポイントです。
src/features/timeline/hostRecordId.ts(抜粋):
1const RECORD_ID_EVENT = 'caseRecordId';
2const REQUEST_RECORD_ID_EVENT = 'requestRecordId';
3
4// モジュール読み込み時(=アプリ起動時)にリスナーを登録する。
5void (async () => {
6 const { createViewSDK } = await import('@salesforce/platform-sdk/view');
7 const viewSdk = await createViewSDK();
8
9 // (1) LWCが push するカスタムイベントを待ち受ける
10 viewSdk?.addEventListener?.(RECORD_ID_EVENT, (event) => {
11 publish(event.detail?.recordId);
12 });
13
14 // (2) リスナーが立ったので「今 recordId を送って」とLWCへ要求する
15 viewSdk?.dispatchEvent?.(new CustomEvent(REQUEST_RECORD_ID_EVENT));
16})();💡
getUiState().state.propsから取れたら良かったのですが、実際に中身をログに出して確かめたところ、レコードページでは props は空({})で recordId は入っていませんでした(props はテーマ・ロケール等の host-configurable inputs 用とされています)。なので recordId を運べるのは上記のカスタムイベントだけ、という結論になりました。(2026年9月時点)
app.tsx の先頭で読み込むことで、要求をできるだけ早く出せます。
1import '@salesforce/platform-sdk/ui-embedding';
2import '@/features/timeline/hostRecordId'; // ここで受信リスナー + requestRecordId が走るあとは React 側のフックでこのストアを購読するだけです。埋め込みでない環境(��ーカル開発)ではルートパラメータにフォールバックします。
1export function useEmbeddedRecordId(): string | undefined {
2 const { recordId: routeRecordId } = useParams();
3 const [hostRecordId, setHostRecordId] = useState(getHostRecordId);
4 useEffect(() => subscribeHostRecordId(setHostRecordId), []);
5 return hostRecordId ?? routeRecordId;
6}recordId が届くまでは GraphQL クエリを投げない、というガードも入れておくと安心です。
1// recordId が来るまではクエリしない(caseId が null で uiapi に弾かれるのを防ぐ)
2const { data, loading, error } = useAsyncData(
3 () => (recordId ? fetchTimeline(recordId) : Promise.resolve(undefined)),
4 [recordId, reloadKey],
5);
6
7if (!recordId) return <NoRecordState />;ステップ4: iframe の高さ(無限に伸びる問題)
lightning-ui-embedding には iframe の高さをコンテンツに合わせるオートリサイザーが入っています。ここで、iframe の中のアプリが 100vh や min-h-screen(ビューポート基準の高さ)を使っていると、
「オートリサイザーが iframe を広げる → 中身の 100vh も一緒に広がる → さらにリサイザーが広げる…」
というループになり、縦にどこまでも伸び続けます。
対策はシンプルで、iframe 内でビューポート基準の高さを使わないことにしました。
1/* 修正前: ビューポート高さがリサイザーにフィードバックして無限に伸びる */
2html, body, #root { @apply min-h-screen; }
3
4/* 修正後: コンテンツ基準の高さにする */
5html, body, #root { height: auto; min-height: 0; }LWC 側の初期高さは LWC のデザイン属性(height、420〜720px にクランプ)で調整できるようにしてあります。
ステップ5: レコードページに配置して動作確認
デプロイしたら、Case レコードページを Lightning アプリケーションビルダーで開き、コンポーネント一覧から 「Case Timeline」 をドラッグして配置します。必要なら右側のプロパティで高さ(Height)を調整します。
保存してレコードページを開くと、そのケースの履歴が 1 本の時間軸に並んで表示されます。
おわりに
Microfrontend / UI Embedding を使うと、React で作った UI を、レコードページの文脈(recordId)を受け取りながら埋め込めます。今回試してみて分かったことを振り返ると、
- 埋め込みは
lightning-ui-embeddingにsrcを渡すだけの薄いラッパー LWC で実現できる - アプリのオリジンは、一度アプリを単体で開いて確認し、[信頼済み URL] に画面から登録しておく
- recordId は、現時点(Beta)では
sf-embeddingチャネルのカスタムイベントで渡すのが素直そう。ready待ちだけだと取りこぼすことがあり、アプリ → LWC の要求(requestRecordId)を挟むと安定した - 細かいハマりどころは「
srcは固定 base URL にする」「iframe 内で100vhを使わない」あたり
あくまで Beta 時点での一例なので、正式リリースでは作法が変わっているかもしれません。とはいえ、LWC と React アプリを橋渡しする感触はつかめました。今後の進化が楽しみです。



