<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Memduh PANPALLI</title>
    <description>The latest articles on DEV Community by Memduh PANPALLI (@panpalli).</description>
    <link>https://dev.to/panpalli</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3953891%2F795dffee-5eb0-499a-a0c4-71bcce9e35a7.jpeg</url>
      <title>DEV Community: Memduh PANPALLI</title>
      <link>https://dev.to/panpalli</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/panpalli"/>
    <language>en</language>
    <item>
      <title>I built /ai inside a notes app — here's how I render generated UI components safely</title>
      <dc:creator>Memduh PANPALLI</dc:creator>
      <pubDate>Wed, 27 May 2026 08:35:55 +0000</pubDate>
      <link>https://dev.to/panpalli/i-built-ai-inside-a-notes-app-heres-how-i-render-generated-ui-components-safely-emc</link>
      <guid>https://dev.to/panpalli/i-built-ai-inside-a-notes-app-heres-how-i-render-generated-ui-components-safely-emc</guid>
      <description>&lt;p&gt;I take a lot of notes while I'm working. Meeting notes, quick ideas, system design sketches — all of it ends up somewhere.&lt;/p&gt;

&lt;p&gt;The frustrating part was never &lt;em&gt;writing&lt;/em&gt; the notes. It was what happened after. I'd describe a UI idea in a note — a color picker, a calculator, a data formatter — and then I'd have to open a separate editor, build the thing, test it, and come back. The note and the tool lived in completely different places.&lt;/p&gt;

&lt;p&gt;At some point I thought: what if the tool just lived &lt;em&gt;inside&lt;/em&gt; the note?&lt;/p&gt;

&lt;p&gt;That's what I've been building for the past few weeks. You type &lt;code&gt;/ai&lt;/code&gt; in the editor, describe what you want, and a working interactive component appears inside your document. Not a screenshot. Not a code block. A real, running UI.&lt;/p&gt;

&lt;p&gt;Here's what that looks like mid-generation:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr16t058g1ga32rvt5m7a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr16t058g1ga32rvt5m7a.png" alt="Fluxerv AI generating a component in real time" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the streaming moment — the AI is writing the component live, and it appears inside the note as it generates.&lt;/p&gt;

&lt;p&gt;Let me walk through how I built this.&lt;/p&gt;




&lt;h2&gt;
  
  
  The editor foundation
&lt;/h2&gt;

&lt;p&gt;I'm using &lt;a href="https://tiptap.dev/" rel="noopener noreferrer"&gt;Tiptap&lt;/a&gt; — a headless, extensible rich text editor for React. Tiptap is built on ProseMirror, which means you can define custom nodes: blocks that behave however you want inside the document.&lt;/p&gt;

&lt;p&gt;I created a custom &lt;code&gt;AiNode&lt;/code&gt; — a Tiptap node that holds generated HTML/CSS/JS as a string in its attributes. When the node renders, it shows a live iframe. The node persists to Supabase as part of the document's JSON content.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AiNodeExtension&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aiComponent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;atom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;addAttributes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple enough. The hard part was &lt;em&gt;getting the code into the node&lt;/em&gt; while streaming.&lt;/p&gt;




&lt;h2&gt;
  
  
  The streaming problem
&lt;/h2&gt;

&lt;p&gt;The AI backend uses Gemini 2.5 Flash via Server-Sent Events. The model streams the HTML, CSS, and JS as one big string — character by character.&lt;/p&gt;

&lt;p&gt;The problem: I needed to show this output &lt;em&gt;live&lt;/em&gt; inside the Tiptap node while it was still being generated. Users shouldn't stare at a spinner for 10 seconds.&lt;/p&gt;

&lt;p&gt;My first approach was to update the rendered output on every streamed chunk. It worked, but it caused too many re-renders and made the editor feel heavy.&lt;/p&gt;

&lt;p&gt;The better approach was to decouple streaming from rendering: accumulate the generated code in a ref while the stream is active, then render the final result once inside a sandboxed iframe.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;accumulated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// On each chunk:&lt;/span&gt;
&lt;span class="nx"&gt;accumulated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;

&lt;span class="c1"&gt;// On stream close:&lt;/span&gt;
&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateAttributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aiComponent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;accumulated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node re-renders once, the iframe appears, the component runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The sandbox setup
&lt;/h2&gt;

&lt;p&gt;The iframe uses this sandbox configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;iframe&lt;/span&gt;
  &lt;span class="na"&gt;sandbox=&lt;/span&gt;&lt;span class="s"&gt;"allow-scripts"&lt;/span&gt;
  &lt;span class="na"&gt;srcdoc=&lt;/span&gt;&lt;span class="s"&gt;{generateSrcdoc(code)}&lt;/span&gt;
&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's missing: &lt;code&gt;allow-same-origin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is intentional and critical. Without &lt;code&gt;allow-same-origin&lt;/code&gt;, the iframe cannot access &lt;code&gt;localStorage&lt;/code&gt;, cookies, or the parent document's DOM. The AI-generated code runs in complete isolation. It can't read your tokens, it can't exfiltrate data, it can't touch anything outside its own sandbox.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;allow-scripts&lt;/code&gt; alone is enough for interactive components to work — timers, event listeners, DOM manipulation inside the frame, all fine.&lt;/p&gt;

&lt;p&gt;I also inject Tailwind CSS via CDN inside the iframe's &lt;code&gt;srcdoc&lt;/code&gt; so generated components can use utility classes without any build step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateSrcdoc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`
    &amp;lt;!DOCTYPE html&amp;gt;
    &amp;lt;html&amp;gt;
      &amp;lt;head&amp;gt;
        &amp;lt;script src="https://cdn.tailwindcss.com"&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;style&amp;gt;
          body { background: #09090b; margin: 0; padding: 16px; }
        &amp;lt;/style&amp;gt;
      &amp;lt;/head&amp;gt;
      &amp;lt;body&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  `&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The refine system
&lt;/h2&gt;

&lt;p&gt;Once a component is generated, users can update it with natural language. There's a "Refine" button in the component toolbar. You type something like "make the background darker" or "add a reset button" — and the component updates.&lt;/p&gt;

&lt;p&gt;The naive approach would be to regenerate the entire component from scratch. That works but it's slow and wasteful.&lt;/p&gt;

&lt;p&gt;Instead, I built a &lt;code&gt;/api/edit&lt;/code&gt; endpoint that receives two things: the current component code and the edit instruction. The system prompt wraps them together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are editing an existing UI component.

Current code:
[CURRENT_CODE]

Edit instruction:
[INSTRUCTION]

Return only the updated full component code. No explanations.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model returns the full updated component — but it only needs to reason about the delta. In practice this is faster and produces more coherent edits than full regeneration.&lt;/p&gt;

&lt;p&gt;The response streams the same way as generation, and the node updates when it's done.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I got wrong
&lt;/h2&gt;

&lt;p&gt;A few things that cost me time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stale closures in Tiptap.&lt;/strong&gt; If you reference &lt;code&gt;editor&lt;/code&gt; inside a &lt;code&gt;useEffect&lt;/code&gt; without including it in the dependency array, you'll be working with a stale editor instance. Commands will silently fail. This one took a while to track down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting before you think you need it.&lt;/strong&gt; I added Upstash Redis rate limiting to both endpoints early on. Good call — during testing I hit Gemini's limits faster than expected. Shared rate limiter utility across both endpoints so I wasn't duplicating logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The middleware gap.&lt;/strong&gt; My Next.js middleware was redirecting unauthenticated users to login — including the &lt;code&gt;/share/&lt;/code&gt; route for public documents and the &lt;code&gt;/api/&lt;/code&gt; routes. Had to explicitly whitelist these paths. Simple fix, annoying to debug.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where it is now
&lt;/h2&gt;

&lt;p&gt;The app is called &lt;strong&gt;Fluxerv&lt;/strong&gt;. It's a workspace where your documents can contain live, interactive tools — not just text.&lt;/p&gt;

&lt;p&gt;Here's a 2-minute demo of it generating a pomodoro timer from a single prompt:&lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2059512944497660320-108" src="https://platform.twitter.com/embed/Tweet.html?id=2059512944497660320"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2059512944497660320-108');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2059512944497660320&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;p&gt;The stack: Next.js (App Router), Tiptap v3, Supabase, Gemini 2.5 Flash (starting point — will scale up as needed), Tailwind CSS, Upstash Redis.&lt;/p&gt;

&lt;p&gt;It's currently in private beta. If you want early access when it opens, follow me on Twitter — I post build updates there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://x.com/MPanpalli" rel="noopener noreferrer"&gt;@MPanpalli on X/Twitter&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've built something similar or ran into the same iframe/streaming problem, I'd love to hear how you solved it. Drop a comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
