Skip to main content

Custom Components

Custom components are React applications that render inside the NoClick interface tab. They run in sandboxed iframes with access to the full @noclick/sdk API, Tailwind CSS, and any npm package.

Creating a Component

From the Canvas

  1. Open the Interface tab in the workflow editor
  2. Drag Custom Component from the UX panel onto the grid
  3. Click the node on the canvas to open its config
  4. Write JSX in the code editor

From MCP (Claude Code, Cursor)

<add_node type="interface-html-react" name="dashboard" label="Dashboard" />
<update_config id="dashboard" operation="jsx" fullscreen="true" />
<update_config id="dashboard" field="jsx_source">
import React from 'react';
import ReactDOM from 'react-dom/client';

function App() {
  return <div className="p-4 text-white">Hello from NoClick!</div>;
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</update_config>

How It Works

  1. You write JSX/TSX in the node’s jsx_source config field
  2. When the node executes, the backend transpiles it via Sucrase (runs in QuickJS, ~15ms)
  3. An import map is generated with React 19 from esm.sh, Tailwind CSS CDN, and any npm packages you import
  4. The @noclick/sdk is injected as a base64 data URI in the import map
  5. Everything is assembled into an HTML document (srcdoc) and rendered in an iframe

Using npm Packages

Just import them — the transpiler auto-detects imports and maps them to esm.sh:
import { BarChart, Bar, XAxis, YAxis } from 'recharts';
import { format } from 'date-fns';
import { motion } from 'framer-motion';
Packages are bundled with &bundle to avoid transitive dependency issues.

Fullscreen Mode

Set fullscreen="true" in the node config to render the component as a full-viewport tab instead of a grid block:
<update_config id="my-component" fullscreen="true" />
When multiple fullscreen components exist, tabs appear below the main tab bar. Tabs are:
  • Draggable — reorder by dragging
  • Renamable — click the active tab label to edit
  • Persistent — tab order survives page reloads

Styling

Tailwind CSS is included automatically. Use standard Tailwind classes:
<div className="flex flex-col items-center gap-4 p-8 bg-zinc-950 text-white min-h-screen">
  <h1 className="text-2xl font-bold">Dashboard</h1>
  <button className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 rounded-lg">
    Click me
  </button>
</div>

Using the SDK

Import any SDK namespace directly:
import { nodes, execution, state, auth, resources, dataset } from '@noclick/sdk';

Read Node Output

const output = await nodes.getOutput('data-fetcher-node-id');

Run a Node and Get Results

// Fire and forget
execution.runNodesInBackground(['node-id']);

// Run with temporary config overrides
execution.runNodesInBackground([
  { id: 'http-node', config: { url: userInput } }
]);

// Stream results as nodes complete
const stream = execution.runNodesAndGetOutput(['fetcher'], ['chart-data', 'summary']);
stream.on('output', (nodeId, data) => {
  if (nodeId === 'chart-data') setChartData(data);
});
await stream.all();

Persistent State

Requires a State Manager node in the workflow:
await state.set('counter', 42);
const val = await state.get('counter');
await state.update('items', list => [...(list || []), newItem]);
await state.del('counter');

// Subscribe to changes
state.onChange('counter', newVal => setCounter(newVal));

OAuth and Credentials

// Check if Gmail is connected
const hasGmail = await auth.hasCredential('google_gmail_oauth');

// Trigger OAuth popup (scopes auto-resolved from node schemas)
if (!hasGmail) {
  const cred = await auth.requestCredential('google_gmail_oauth');
}

// Create API key credential
const cred = await auth.createCredential('telegram_bot_token', { token: key });

File Upload

const { resourceId, uploadUrl } = await resources.upload('photo.jpg', 'image/jpeg', file.size);
await fetch(uploadUrl, { method: 'PUT', body: file });
const downloadUrl = await resources.getUrl(resourceId);

Dataset CRUD

const dsId = await dataset.create('User Submissions');
await dataset.appendRows(dsId, [{ name: 'Alice', score: 95 }]);
const page = await dataset.getRows(dsId, { limit: 100 });
await dataset.deleteRows(dsId, [rowId]);

Caching

Compiled srcdoc is cached in IndexedDB (one slot per node). Components render instantly on tab switch and page reload without re-transpilation. The cache auto-invalidates when jsx_source changes. Copied/duplicated nodes share cache via source hash matching.

Troubleshooting

IssueSolution
Gray iframe on first loadesm.sh cold start (~3-5s). Packages are cached after first load.
&lt; in stored JSXUse field="jsx_source" body syntax in MCP XML. Entity escaping is handled automatically.
{{ }} in JSX strippedFixed — unresolvable {{ }} patterns (like JSX inline styles) are preserved as-is.
Drag/resize stuck on canvasFixed — pointer-events: none on iframes during drag/resize via CSS.
SDK method hangsAll requests timeout after 30s with a descriptive error.