March 27, 2026
Your users want custom fields. They want to add "Industry" to contacts, "Contract Value" to deals, and "Preferred Timezone" to their user profiles. You do not want to build the database schema, validation engine, multi-tenant isolation, and dynamic form renderer required to support that.
This tutorial shows how to add fully functional custom fields to a React application using the Kopra TypeScript SDK. The end result: your users configure and fill in custom fields through an embedded editor that lives inside your app.
By the end of this tutorial, your React app will:
Here is what the integration looks like at a high level:
| Step | Where | What Happens |
|---|---|---|
| 1 | Backend | Your server calls Kopra's token endpoint with the tenant ID and field group |
| 2 | Frontend | The SDK receives the token and loads the field editor iframe |
| 3 | User | The user fills in custom field values in the embedded editor |
| 4 | Frontend | The SDK fires onFieldsSaved when the user saves |
| 5 | Backend | Optionally, your server reads values via the REST API for server-side logic |
Before starting, you need:
key (e.g., customer)If you do not have a Kopra account yet, sign up at kopra.dev. The free tier includes 2 field groups, 3 tenants, and 1,000 API calls per month.
Install the Kopra SDK in your React project:
npm install @kopra-dev/sdkThe SDK is a lightweight TypeScript package. It manages iframe creation, postMessage communication, token exchange, and event handling. No additional dependencies required.
| Package | Size | Dependencies | TypeScript |
|---|---|---|---|
@kopra-dev/sdk | ~8 KB gzipped | 0 | Full type definitions included |
The SDK needs a short-lived JWT token to authenticate iframe communication. Your backend generates this token by calling Kopra's token endpoint with your API key. The API key never reaches the browser.
Create a token endpoint in your Express server:
// server/routes/kopra-token.ts
import express from 'express';
const router = express.Router();
const KOPRA_API_URL = process.env.KOPRA_API_URL; // e.g., https://your-kopra-instance.com
const KOPRA_API_KEY = process.env.KOPRA_API_KEY; // e.g., kp_your_key
router.post('/api/kopra-token', async (req, res) => {
const { tenantId, fieldGroupKey, fieldLimit } = req.body;
// Validate that the requesting user has access to this tenant
// (your own auth logic here)
const response = await fetch(`${KOPRA_API_URL}/api/auth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': KOPRA_API_KEY,
},
body: JSON.stringify({ tenantId, fieldGroupKey, fieldLimit }),
});
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data });
}
// The SDK expects the token response directly
return res.json(data.data);
});
export default router;The token response contains:
{
"token": "eyJ...",
"fieldEditorUrl": "https://your-kopra-instance.com/embed/field-editor",
"tenantConfigUrl": "https://your-kopra-instance.com/embed/tenant-config",
"fieldsGroupId": "uuid-of-resolved-field-group",
"expiresAt": "2026-03-28T12:00:00.000Z"
}The fieldEditorUrl and tenantConfigUrl are the iframe source URLs. The fieldsGroupId is the resolved UUID for the field group key you provided. The SDK uses all of these internally.
Security note: Token generation is rate-limited to 50 requests per 15 minutes per API key. Tokens expire after the duration set in the response (default: 24 hours, max: 7 days). Always validate that the requesting user has permission to access the tenant before generating a token.
Create a custom hook or initialize the SDK in your component. The SDK constructor takes a KopraSDKConfig object:
import { KopraSDK, type KopraSDKConfig } from '@kopra-dev/sdk';
const config: KopraSDKConfig = {
currentTenant: 'northstar-staffing',
getToken: async (req) => {
const response = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!response.ok) {
throw new Error(`Token request failed: ${response.status}`);
}
return response.json();
},
};
const sdk = new KopraSDK(config);The getToken callback is called by the SDK whenever it needs a new token. The SDK passes a TokenRequest object with tenantId, fieldGroupKey, and optionally fieldLimit. Your callback sends this to your backend, which forwards it to Kopra's token endpoint.
The currentTenant string is your identifier for the customer. It scopes all field operations to that tenant.
The loadCustomFields method creates an iframe inside a container element and loads the field editor for a specific entity:
import { useEffect, useRef } from 'react';
import { KopraSDK, type KopraSDKConfig } from '@kopra-dev/sdk';
interface CustomFieldsEditorProps {
tenantId: string;
entityId: string;
fieldGroupKey: string;
}
export function CustomFieldsEditor({
tenantId,
entityId,
fieldGroupKey,
}: CustomFieldsEditorProps) {
const sdkRef = useRef<KopraSDK | null>(null);
useEffect(() => {
const sdk = new KopraSDK({
currentTenant: tenantId,
getToken: async (req) => {
const res = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
return res.json();
},
onFieldsSaved: (values) => {
console.log('Fields saved:', values);
},
onFieldsError: (error) => {
console.error('Field editor error:', error);
},
});
sdkRef.current = sdk;
sdk.loadCustomFields('kopra-fields', {
fieldGroupKey,
entityId,
});
return () => {
sdk.destroy();
};
}, [tenantId, entityId, fieldGroupKey]);
return <div id="kopra-fields" />;
}The loadCustomFields method requires two options:
| Option | Type | Required | Description |
|---|---|---|---|
fieldGroupKey | string | Yes | The key of the field group (e.g., 'customer') |
entityId | string | Yes | The ID of the entity in your system (e.g., 'contact-42') |
initialHeight | string | No | Initial iframe height (default: '100px', auto-resizes) |
theme | ThemeConfig | No | Custom theme object (see Step 7) |
The iframe auto-resizes to fit its content. The entityId is your own identifier for the record. Kopra stores field values keyed to this ID.
In addition to the field editor (where users fill in values), you can embed the field configuration panel. This lets your tenants create and manage their own custom fields without filing support tickets.
import { useEffect, useRef } from 'react';
import { KopraSDK } from '@kopra-dev/sdk';
interface FieldConfigProps {
tenantId: string;
fieldGroupKey: string;
fieldLimit?: number;
}
export function FieldConfig({
tenantId,
fieldGroupKey,
fieldLimit = 10,
}: FieldConfigProps) {
const sdkRef = useRef<KopraSDK | null>(null);
useEffect(() => {
const sdk = new KopraSDK({
currentTenant: tenantId,
getToken: async (req) => {
const res = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
return res.json();
},
onConfigSaved: (data) => {
console.log('Configuration saved:', data);
},
onConfigError: (error) => {
console.error('Configuration error:', error);
},
});
sdkRef.current = sdk;
sdk.loadFieldConfiguration('kopra-config', {
fieldGroupKey,
fieldLimit,
});
return () => {
sdk.destroy();
};
}, [tenantId, fieldGroupKey, fieldLimit]);
return <div id="kopra-config" />;
}The loadFieldConfiguration method options:
| Option | Type | Required | Description |
|---|---|---|---|
fieldGroupKey | string | Yes | The key of the field group to configure |
fieldLimit | number | No | Maximum number of fields the tenant can create |
height | string | No | Iframe height (default: '600px') |
The fieldLimit parameter is useful for tiering. A free plan might allow 5 tenant fields while a paid plan allows 50. This limit is enforced server-side through the JWT token.
The SDK communicates with your React app through callback functions defined in the config:
const sdk = new KopraSDK({
currentTenant: 'northstar-staffing',
getToken: async (req) => {
const res = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
return res.json();
},
// Called when field values are saved in the editor
onFieldsSaved: (values) => {
// values is a Record<string, unknown>
// e.g., { industry: "Healthcare", contract_value: 50000 }
console.log('Saved values:', values);
// Update your local state, trigger a re-fetch, etc.
},
// Called when the field editor encounters an error
onFieldsError: (error) => {
console.error('Field editor error:', error);
// Show an error notification in your app
},
// Called when tenant field configuration is saved
onConfigSaved: (data) => {
console.log('Config updated:', data);
// Optionally reload the field editor to show new fields
},
// Called when the configuration panel encounters an error
onConfigError: (error) => {
console.error('Config error:', error);
},
});The SDK also provides methods for programmatic control:
// Save fields from your own button (not the iframe's save button)
const result = await sdk.saveFields();
// result: { success: boolean, errors?: Record<string, string>, values?: Record<string, unknown> }
// Validate without saving (dry run)
const validation = await sdk.validateFields();
// validation: { valid: boolean, errors: Record<string, string> }
// Get current field values without saving
const currentValues = await sdk.getFieldValues();
// currentValues: Record<string, unknown>The SDK supports autosave with configurable debouncing:
const sdk = new KopraSDK({
currentTenant: 'northstar-staffing',
getToken: async (req) => { /* ... */ },
autosave: {
enabled: true,
debounceMs: 2000, // Wait 2 seconds after last change
validateBeforeSave: true,
onAutosaveStart: () => {
// Show a "Saving..." indicator
},
onAutosaveSuccess: (values) => {
// Show a "Saved" indicator
},
onAutosaveError: (error) => {
// Show an error notification
},
},
});You can also enable or disable autosave dynamically:
sdk.enableAutosave({ debounceMs: 3000 });
sdk.disableAutosave();The field editor runs inside an iframe, but you can control its appearance through a theme object. Every visual element supports both CSS class names and inline style objects:
import { type ThemeConfig } from '@kopra-dev/sdk';
const theme: ThemeConfig = {
container: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '16px',
fontFamily: 'Inter, system-ui, sans-serif',
},
label: {
fontSize: '14px',
fontWeight: '500',
color: '#374151',
marginBottom: '6px',
},
input: {
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
backgroundColor: '#ffffff',
color: '#111827',
},
inputFocus: {
outline: 'none',
borderColor: '#3b82f6',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)',
},
select: {
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
},
textarea: {
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
minHeight: '80px',
resize: 'vertical',
},
saveButton: {
display: 'none', // Hide the iframe's save button; use your own
},
};
await sdk.loadCustomFields('kopra-fields', {
fieldGroupKey: 'customer',
entityId: 'contact-42',
theme,
});Available theme keys:
| Key | What It Styles |
|---|---|
container / containerClass | The outer wrapper around all fields |
fieldContainer / fieldContainerClass | Each field's wrapper (multi-column layout) |
fieldContainerSingle / fieldContainerSingleClass | Fields that span full width (e.g., textarea) |
fieldGlobal / fieldGlobalClass | Fields defined by you (the SaaS owner) |
fieldTenant / fieldTenantClass | Fields defined by the tenant |
label / labelClass | Field labels |
input / inputClass | Text inputs and number inputs |
inputFocus | Focus state styles for inputs |
select / selectClass | Select dropdowns |
textarea / textareaClass | Textarea fields |
saveButton / saveButtonClass | The save button |
Each key has two variants: the plain key (e.g., input) takes an inline style object, and the Class variant (e.g., inputClass) takes a CSS class name string. If both are provided, inline styles win on conflict.
The SDK ships with a sensible default theme. You can retrieve it for inspection:
import { getDefaultTheme } from '@kopra-dev/sdk';
const defaults = getDefaultTheme();
console.log(defaults);Here is the full integration in one file. In a real application you would split this into separate components and a custom hook.
// components/ContactCustomFields.tsx
import { useEffect, useRef, useCallback, useState } from 'react';
import { KopraSDK, type KopraSDKConfig, type ThemeConfig } from '@kopra-dev/sdk';
interface ContactCustomFieldsProps {
tenantId: string;
contactId: string;
}
const theme: ThemeConfig = {
container: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '16px',
fontFamily: 'Inter, system-ui, sans-serif',
},
label: {
fontSize: '14px',
fontWeight: '500',
color: '#374151',
marginBottom: '6px',
},
input: {
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
},
inputFocus: {
outline: 'none',
borderColor: '#3b82f6',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)',
},
select: {
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
},
textarea: {
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
minHeight: '80px',
},
saveButton: { display: 'none' },
};
export function ContactCustomFields({
tenantId,
contactId,
}: ContactCustomFieldsProps) {
const sdkRef = useRef<KopraSDK | null>(null);
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>(
'idle'
);
const getToken = useCallback(
async (req: { tenantId: string; fieldGroupKey?: string }) => {
const response = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!response.ok) {
throw new Error(`Token request failed: ${response.status}`);
}
return response.json();
},
[]
);
useEffect(() => {
const sdk = new KopraSDK({
currentTenant: tenantId,
getToken,
onFieldsSaved: (values) => {
console.log('Custom fields saved:', values);
setStatus('saved');
},
onFieldsError: (error) => {
console.error('Custom fields error:', error);
setStatus('error');
},
autosave: {
enabled: true,
debounceMs: 2000,
validateBeforeSave: true,
onAutosaveStart: () => setStatus('saving'),
onAutosaveSuccess: () => setStatus('saved'),
onAutosaveError: () => setStatus('error'),
},
});
sdkRef.current = sdk;
sdk.loadCustomFields('contact-custom-fields', {
fieldGroupKey: 'customer',
entityId: contactId,
theme,
});
return () => {
sdk.destroy();
};
}, [tenantId, contactId, getToken]);
const handleSave = async () => {
if (!sdkRef.current) return;
setStatus('saving');
try {
const result = await sdkRef.current.saveFields();
if (result.success) {
setStatus('saved');
} else {
setStatus('error');
console.error('Validation errors:', result.errors);
}
} catch {
setStatus('error');
}
};
return (
<div>
<div id="contact-custom-fields" />
<div style={{ marginTop: '12px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<button onClick={handleSave} disabled={status === 'saving'}>
Save Custom Fields
</button>
{status === 'saving' && <span>Saving...</span>}
{status === 'saved' && <span>Saved</span>}
{status === 'error' && <span>Error saving fields</span>}
</div>
</div>
);
}Usage in a parent component:
<ContactCustomFields tenantId="northstar-staffing" contactId="contact-42" />Understanding the flow helps with debugging:
loadCustomFields calls your getToken callback with { tenantId, fieldGroupKey }.POST /api/auth/token with your API key and returns the token response.src to the fieldEditorUrl from the token response.ready postMessage. The SDK responds with the JWT token, field group ID, entity ID, and theme.saved postMessage. The SDK calls your onFieldsSaved callback.All communication uses postMessage with origin validation. The SDK only accepts messages from the origins specified in the token response URLs.
Use the REST API to read custom field values in your backend logic:
# Read field values for an entity
curl -X GET \
"https://your-kopra-instance.com/api/tenants/northstar-staffing/field-groups/{fgId}/entities/contact-42/values" \
-H "X-API-Key: kp_your_key"
# Search across field values
curl -X GET \
"https://your-kopra-instance.com/api/field-values/search?tenantId=northstar-staffing&fieldKey=industry&value=Healthcare" \
-H "X-API-Key: kp_your_key"The REST API provides 26 endpoints covering field groups, global fields, tenant fields, field values, webhooks, and search. Full documentation is available at /api/docs (Swagger UI) and /api/docs.json (OpenAPI 3.0 spec).
Set up webhooks to react to field changes server-side:
curl -X POST "https://your-kopra-instance.com/api/webhooks" \
-H "X-API-Key: kp_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/kopra",
"events": ["field_value.saved", "field_value.deleted"]
}'Webhook deliveries include an X-Kopra-Signature header (HMAC-SHA256) for verification. Failed deliveries retry with exponential backoff.
If your application integrates with AI agents, Kopra provides an MCP (Model Context Protocol) server that lets AI assistants manage custom fields programmatically:
{
"mcpServers": {
"kopra": {
"command": "npx",
"args": ["-y", "@kopra-dev/mcp"],
"env": {
"KOPRA_API_KEY": "kp_your_key"
}
}
}
}The MCP server exposes 16 tools for field groups, global fields, tenant fields, field values, and webhooks.
| What | How |
|---|---|
| Install | npm install @kopra-dev/sdk |
| Backend | One POST endpoint that proxies token requests |
| Frontend | new KopraSDK({ currentTenant, getToken }) |
| Load fields | sdk.loadCustomFields(containerId, { fieldGroupKey, entityId }) |
| Load config | sdk.loadFieldConfiguration(containerId, { fieldGroupKey }) |
| Save | sdk.saveFields() or autosave |
| Events | onFieldsSaved, onFieldsError, onConfigSaved, onConfigError |
| Theme | Pass a ThemeConfig object to loadCustomFields |
The full SDK is open source and typed. The free tier at kopra.dev includes everything in this tutorial. No credit card required.