By Jeremias Kastensmidt

Most “embeddable” products are not actually embeddable. They either force you into their stack, break your UI when you try to integrate them, or assume access to data and systems they should not have. You can usually fix one or two, then the third one breaks everything again. We hit this early, so the requirement became strict very quickly: ship a full lending experience inside someone else’s product without inheriting their stack, touching their styles, or crossing trust boundaries. If any of those break, the integration is not real.
Partners are not blank environments. They have already made decisions about frameworks, auth, data flow, and design. Some are newer stacks, most are legacy, and you have to work with all of them. You do not get to negotiate that. If your system fights theirs, it loses. So the question becomes simple: what is the smallest surface that still works? One tag. That is it. That became <prime-embed>.
We tried the obvious approaches first. React SDK sounds clean until you realize you either force the partner to use React or you ship your own React runtime inside their application. Both are non-starters. Nobody wants to adopt your framework just to use one feature. Script with DOM manipulation feels flexible at first, then styles collide, selectors break, and small changes on the host leak into the embed or into the partner system depending on how it is wired. You end up debugging things you do not control. Web Components were the first approach that did not fight the environment. Shadow DOM isolates styles and does not care about the host framework. That solved part of the problem, but we still had to solve the important part. Styling is not what breaks these systems. Control is. If you do not control execution, you do not control anything.
The architecture and what actually matters

We split the system in two and that is where things started to work. On the host side (the partner’s application) there is almost nothing: a small script that defines <prime-embed>, attaches a Shadow DOM, renders an iframe, and forwards messages. No business logic, no state, no assumptions. Inside the iframe is our system, a full Next.js app that owns the UI, the state machine, the API calls, and the business logic. The integration ends up very small:
<prime-embed
customer-id="cust_abc123"
on-consent="handlePrimeConsent"
landing-href="/prime-lending"
></prime-embed>
<script src="https://app.primeft.com/scripts/initialize.js"></script>
That is the entire surface. It also makes the low-code implementation clean and fast. No SDK setup, no deep integration work, no back-and-forth to make things fit. Drop the tag, wire the callback, and it works. We set a hard constraint for ourselves: someone managing a WordPress page should be able to implement this in under five minutes. If that is not true, the design is wrong.
There was also a product constraint behind it. When an executive asks their engineering team “how long will it take to integrate this?”, the answer should be the same every time. One day, at most. Not a month, not a week. If the answer varies, or depends on stack, or needs planning, it will not get prioritized.
If integration is not fast, why even do it?
At this point you have two systems: the partner’s app (host) and our app (inside the iframe). Once you accept the iframe is our system and the host is just an adapter, most complexity disappears. The iframe and the host are two different systems, so we treat communication like a protocol over postMessage. A small set of events, ready, init, consent, response. Nothing more. The important part is that it is explicit. It behaves like a client-server boundary inside the browser.
The part that broke most early versions was consent. We need partner-owned data but we cannot call their APIs directly and we should not implicitly access sensitive data. The user has to allow it and the partner controls how that data is fetched. So we made it an async handshake: user clicks “allow” inside the iframe, the iframe emits an event, the host calls a partner-defined callback, the partner fetches the data, the host responds, the iframe continues. From inside the iframe it behaves like a normal async function, await onConsent(), but it is crossing a boundary we do not control. Earlier versions tried to pass data synchronously and that failed quickly because the data is not always available. Async is not a design choice here, it is a requirement.
Then state. We started with flags like most systems do, isEligible, hasConsent, isLoading. It works until it does not. You get combinations that do not make sense and you spend time debugging states that should not exist. We replaced that with an explicit state machine. Acquisition, Eligible, Consent, Sharing Data, Conversion, In Review, Approved, Active. Some names are slightly redundant and that is fine because they map directly to backend reality. Users leave and come back and land exactly where they should, no guesswork.
Looking back, a few things are obvious. Shadow DOM solves styling, not execution. When sensitive data is involved, execution boundaries matter more, so the iframe became non-negotiable. Consent must be async because partners fetch data on their own timeline. The host script must remain small because large third-party scripts immediately reduce adoption. And the main point is to own the boundary. Do not blend systems, do not share state, do not duplicate logic. The iframe is the source of truth and the host reacts. That removes entire classes of problems.
So you are not embedding UI, you are embedding a system.
Once that is correct, everything else becomes straightforward.
Want to learn more? Click here to schedule a demo.