← Back to blog

Add Custom Fields to Your React App in 5 Minutes

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.

What You Will Build

By the end of this tutorial, your React app will:

  1. Generate a short-lived token on the backend (keeps your API key off the client)
  2. Render an embedded field editor for any entity in your system
  3. Let tenants configure their own fields through a self-service panel
  4. Listen for save events so your app stays in sync
  5. Match your application's visual style through theming

Here is what the integration looks like at a high level:

StepWhereWhat Happens
1BackendYour server calls Kopra's token endpoint with the tenant ID and field group
2FrontendThe SDK receives the token and loads the field editor iframe
3UserThe user fills in custom field values in the embedded editor
4FrontendThe SDK fires onFieldsSaved when the user saves
5BackendOptionally, your server reads values via the REST API for server-side logic

Prerequisites

Before starting, you need:

  • A React application (Create React App, Vite, Next.js, or any React setup)
  • A Node.js/Express backend (or equivalent server that can make API calls)
  • A Kopra account with an API key (free tier works)
  • At least one field group created in the Kopra dashboard with a 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.

Step 1: Install the SDK

Install the Kopra SDK in your React project:

npm install @kopra-dev/sdk

The SDK is a lightweight TypeScript package. It manages iframe creation, postMessage communication, token exchange, and event handling. No additional dependencies required.

PackageSizeDependenciesTypeScript
@kopra-dev/sdk~8 KB gzipped0Full type definitions included

Step 2: Create a Backend Token Endpoint

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.

Step 3: Initialize KopraSDK in Your React Component

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.

Step 4: Load the Field Editor

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:

OptionTypeRequiredDescription
fieldGroupKeystringYesThe key of the field group (e.g., 'customer')
entityIdstringYesThe ID of the entity in your system (e.g., 'contact-42')
initialHeightstringNoInitial iframe height (default: '100px', auto-resizes)
themeThemeConfigNoCustom 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.

Step 5: Load Tenant Configuration

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:

OptionTypeRequiredDescription
fieldGroupKeystringYesThe key of the field group to configure
fieldLimitnumberNoMaximum number of fields the tenant can create
heightstringNoIframe 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.

Step 6: Listen for Events

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);
  },
});

Programmatic Save and Validation

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>

Autosave

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();

Step 7: Theming to Match Your App

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:

KeyWhat It Styles
container / containerClassThe outer wrapper around all fields
fieldContainer / fieldContainerClassEach field's wrapper (multi-column layout)
fieldContainerSingle / fieldContainerSingleClassFields that span full width (e.g., textarea)
fieldGlobal / fieldGlobalClassFields defined by you (the SaaS owner)
fieldTenant / fieldTenantClassFields defined by the tenant
label / labelClassField labels
input / inputClassText inputs and number inputs
inputFocusFocus state styles for inputs
select / selectClassSelect dropdowns
textarea / textareaClassTextarea fields
saveButton / saveButtonClassThe 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);

Complete Working Example

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" />

How It Works Under the Hood

Understanding the flow helps with debugging:

  1. SDK constructor stores config. No network calls yet.
  2. loadCustomFields calls your getToken callback with { tenantId, fieldGroupKey }.
  3. Your backend calls POST /api/auth/token with your API key and returns the token response.
  4. The SDK creates an iframe, sets its src to the fieldEditorUrl from the token response.
  5. The iframe loads and sends a ready postMessage. The SDK responds with the JWT token, field group ID, entity ID, and theme.
  6. The iframe authenticates with the token and renders the field editor.
  7. When the user changes values, the iframe notifies the SDK (which triggers autosave if enabled).
  8. On save, the iframe validates and persists values, then sends a 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.

Next Steps

Server-Side Access via REST API

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).

Webhooks for Real-Time Sync

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.

MCP Server for AI Agents

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.

Summary

WhatHow
Installnpm install @kopra-dev/sdk
BackendOne POST endpoint that proxies token requests
Frontendnew KopraSDK({ currentTenant, getToken })
Load fieldssdk.loadCustomFields(containerId, { fieldGroupKey, entityId })
Load configsdk.loadFieldConfiguration(containerId, { fieldGroupKey })
Savesdk.saveFields() or autosave
EventsonFieldsSaved, onFieldsError, onConfigSaved, onConfigError
ThemePass 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.