← Back to blog

Adding Custom Fields to an Express + React App: A Complete Walkthrough

April 3, 2026

BookingPilot is a B2B appointment scheduling platform. Clinics use it to manage patient bookings, and for months the most common feature request has been the same: "Can we add custom fields to the booking form?"

Sunrise Dental Clinic wants to collect insurance numbers. A physiotherapy practice needs a "Preferred Contact Method" dropdown. A dermatology office wants patient notes attached to every appointment. Every clinic has different requirements, and building a dynamic field system from scratch would take the team months of database schema work, validation logic, multi-tenant isolation, and a form renderer.

This walkthrough covers the full integration of Kopra into BookingPilot, from the first line of code to the final result. Not just the SDK setup, but the complete flow: backend token endpoint, frontend editor, reading values server-side for email notifications, theming, and tenant self-service.

Setting Up Kopra

After creating a Kopra account, BookingPilot's team creates a field group called Booking Details with the key booking-details. They add four global fields that every clinic gets by default:

FieldKeyTypeRequired
Insurance Numberinsurance_numberTextYes
Service Typeservice_typeSelect (Cleaning, Consultation, Emergency, Follow-up)Yes
Patient Notespatient_notesTextareaNo
Preferred Contact Methodpreferred_contactSelect (Phone, Email, SMS)No

These global fields appear for every clinic. Individual clinics can then add their own tenant-specific fields on top (like "Referral Source" or "Allergies") through the self-service config panel.

Backend: Express Token Endpoint with Tenant Tiering

The Kopra SDK needs a short-lived JWT token to authenticate. BookingPilot's Express backend generates this token by calling Kopra's token endpoint, keeping the API key server-side. The fieldLimit parameter enforces plan-based restrictions: free clinics can create up to 5 custom fields, pro clinics get 20.

// server/routes/kopra.ts
import express from 'express';
import { authenticateClinic } from '../middleware/auth';

const router = express.Router();

const KOPRA_API_URL = process.env.KOPRA_API_URL;
const KOPRA_API_KEY = process.env.KOPRA_API_KEY;

const FIELD_LIMITS: Record<string, number> = {
  free: 5,
  pro: 20,
};

router.post('/api/kopra-token', authenticateClinic, async (req, res) => {
  const clinic = req.clinic; // set by authenticateClinic middleware
  const { fieldGroupKey } = req.body;

  const fieldLimit = FIELD_LIMITS[clinic.plan] ?? 5;

  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: clinic.id,
      fieldGroupKey,
      fieldLimit,
    }),
  });

  const data = await response.json();

  if (!response.ok) {
    return res.status(response.status).json({ error: data });
  }

  return res.json(data.data);
});

export default router;

The tenantId is the clinic's ID from BookingPilot's own database. Kopra isolates all data per tenant automatically. Tenants are provisioned on first use, so there is no need to create them in advance.

Frontend: Loading the Field Editor for a Booking

When a patient opens their booking details page, BookingPilot renders the Kopra field editor for that specific booking. The entityId is the booking ID from BookingPilot's database.

// components/BookingCustomFields.tsx
import { useEffect, useRef } from 'react';
import { KopraSDK } from '@kopra-dev/sdk';

interface BookingCustomFieldsProps {
  clinicId: string;
  bookingId: string;
}

export function BookingCustomFields({ clinicId, bookingId }: BookingCustomFieldsProps) {
  const sdkRef = useRef<KopraSDK | null>(null);

  useEffect(() => {
    const sdk = new KopraSDK({
      currentTenant: clinicId,
      getToken: async (req) => {
        const res = await fetch('/api/kopra-token', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(req),
        });
        if (!res.ok) throw new Error(`Token request failed: ${res.status}`);
        return res.json();
      },
      onFieldsSaved: (values) => {
        console.log('Booking fields saved:', values);
      },
      onFieldsError: (error) => {
        console.error('Field editor error:', error);
      },
    });

    sdkRef.current = sdk;

    sdk.loadCustomFields('booking-fields', {
      fieldGroupKey: 'booking-details',
      entityId: bookingId,
    });

    return () => {
      sdk.destroy();
    };
  }, [clinicId, bookingId]);

  return <div id="booking-fields" />;
}

Usage in a parent component:

<BookingCustomFields clinicId="sunrise-dental" bookingId="booking-1042" />

The iframe auto-resizes to fit its content. If Sunrise Dental has the four global fields plus two tenant-specific fields they created, the editor renders all six.

Saving: Autosave and the onFieldsSaved Callback

Autosave is enabled by default. When a clinic receptionist stops typing for 2 seconds, the values persist automatically. The embedded editor shows a brief "Changes saved" indicator inside the iframe.

The onFieldsSaved callback fires on every save, giving BookingPilot the opportunity to react. For example, updating a local cache or marking the booking as "details complete":

onFieldsSaved: (values) => {
  // values: { insurance_number: "INS-4421-B", service_type: "Consultation", ... }
  if (values.insurance_number && values.service_type) {
    markBookingDetailsComplete(bookingId);
  }
},

For workflows where BookingPilot needs to validate before saving (like a multi-step form), autosave can be disabled and saves triggered programmatically:

sdk.disableAutosave();

// Later, when the user clicks "Confirm Booking":
const result = await sdk.saveFields();
if (result.success) {
  submitBooking();
}

Reading Values Server-Side via REST API

When Dr. Emily Chen has her next appointment, BookingPilot sends a confirmation email that includes the custom field data. The backend reads these values using the REST API:

// server/services/bookingNotifications.ts
async function getBookingFieldValues(
  clinicId: string,
  fieldGroupId: string,
  bookingId: string
): Promise<Record<string, unknown>> {
  const response = await fetch(
    `${KOPRA_API_URL}/api/tenants/${clinicId}/field-groups/${fieldGroupId}/entities/${bookingId}/values`,
    {
      headers: { 'X-API-Key': KOPRA_API_KEY! },
    }
  );

  const data = await response.json();
  return data.data; // { insurance_number: "INS-4421-B", service_type: "Consultation", ... }
}

async function sendBookingConfirmation(bookingId: string) {
  const booking = await getBooking(bookingId); // BookingPilot's own data
  const fieldValues = await getBookingFieldValues(
    booking.clinicId,
    booking.fieldGroupId,
    bookingId
  );

  await sendEmail({
    to: booking.patientEmail,
    subject: `Booking Confirmed - ${booking.providerName}`,
    body: `
      Your appointment with Dr. Emily Chen is confirmed.
      Date: ${booking.date}
      Service: ${fieldValues.service_type}
      Insurance: ${fieldValues.insurance_number}
      ${fieldValues.patient_notes ? `Notes: ${fieldValues.patient_notes}` : ''}
    `,
  });
}

BookingPilot can also use the search endpoint to query across bookings. For instance, finding all bookings at Sunrise Dental where the service type is "Emergency":

curl -X GET \
  "https://kopra.dev/api/kopra/field-values/search?tenantId=sunrise-dental&fieldKey=service_type&value=Emergency" \
  -H "X-API-Key: kp_bookingpilot_key"

Theming to Match BookingPilot's Design

BookingPilot uses a clean blue-and-white design system. The Kopra field editor runs inside an iframe, but its appearance is fully configurable through a theme object:

import { type ThemeConfig } from '@kopra-dev/sdk';

const bookingPilotTheme: ThemeConfig = {
  container: {
    display: 'grid',
    gridTemplateColumns: '1fr 1fr',
    gap: '14px',
    fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif',
  },
  label: {
    fontSize: '13px',
    fontWeight: '600',
    color: '#1e3a5f',
    marginBottom: '4px',
  },
  input: {
    padding: '10px 14px',
    border: '1px solid #c7d2e0',
    borderRadius: '8px',
    fontSize: '14px',
    backgroundColor: '#f8fafc',
  },
  inputFocus: {
    borderColor: '#2563eb',
    boxShadow: '0 0 0 3px rgba(37, 99, 235, 0.12)',
    outline: 'none',
  },
  select: {
    padding: '10px 14px',
    border: '1px solid #c7d2e0',
    borderRadius: '8px',
    fontSize: '14px',
    cursor: 'pointer',
  },
  textarea: {
    padding: '10px 14px',
    border: '1px solid #c7d2e0',
    borderRadius: '8px',
    fontSize: '14px',
    minHeight: '90px',
  },
  saveButton: { display: 'none' },
};

sdk.loadCustomFields('booking-fields', {
  fieldGroupKey: 'booking-details',
  entityId: 'booking-1042',
  theme: bookingPilotTheme,
});

The saveButton: { display: 'none' } hides the iframe's built-in save button since BookingPilot relies on autosave and its own "Confirm Booking" button.

Tenant Self-Service: Clinics Managing Their Own Fields

The real power of Kopra is letting tenants configure their own fields. BookingPilot adds a "Customize Booking Form" section to each clinic's settings page. The loadFieldConfiguration method renders a management panel where clinic admins can add, edit, and reorder their custom fields.

// components/ClinicFieldSettings.tsx
import { useEffect } from 'react';
import { KopraSDK } from '@kopra-dev/sdk';

interface ClinicFieldSettingsProps {
  clinicId: string;
  plan: 'free' | 'pro';
}

export function ClinicFieldSettings({ clinicId, plan }: ClinicFieldSettingsProps) {
  useEffect(() => {
    const fieldLimit = plan === 'pro' ? 20 : 5;

    const sdk = new KopraSDK({
      currentTenant: clinicId,
      getToken: async (req) => {
        const res = await fetch('/api/kopra-token', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ ...req, fieldLimit }),
        });
        return res.json();
      },
      onConfigSaved: () => {
        console.log('Clinic field configuration updated');
      },
    });

    sdk.loadFieldConfiguration('clinic-config', {
      fieldGroupKey: 'booking-details',
      fieldLimit,
    });

    return () => sdk.destroy();
  }, [clinicId, plan]);

  return (
    <div>
      <h3>Customize Booking Form</h3>
      <p>Add custom fields that patients fill out when booking an appointment.</p>
      <div id="clinic-config" />
    </div>
  );
}

Sunrise Dental's office manager can now add an "Allergies" text field and a "Referral Source" dropdown without filing a support ticket. The field limit (5 for free, 20 for pro) is enforced server-side through the JWT token, so upgrading is a natural upsell moment.

The Result

Here is what BookingPilot shipped and what it took:

MetricValue
Backend code1 Express route (~30 lines)
Frontend code2 React components (~80 lines total)
Server-side reads1 helper function (~15 lines)
Total integration code~125 lines of TypeScript
Time to integrateLess than a day
Time if built from scratch4-8 weeks (schema design, validation, multi-tenant isolation, UI, API)

BookingPilot's engineering cost for the integration was roughly one developer-day. Building the equivalent feature internally would have required designing a dynamic schema system, building a form renderer with validation, implementing multi-tenant data isolation, creating an admin UI for field management, and writing API endpoints for server-side access. That is a quarter-long project at minimum.

The ongoing cost is a Kopra subscription that scales with usage. For BookingPilot's 200 clinic customers generating around 15,000 bookings per month, this is a fraction of what a single engineer's time would cost to maintain a custom solution.

More importantly, BookingPilot shipped the feature within a week of the first customer request. Sunrise Dental is already using their custom fields in production, Dr. Emily Chen's confirmation emails include insurance details, and three more clinics signed up for the pro plan specifically to get more custom fields.

The code from this walkthrough is available as a working example in the Kopra documentation. To get started with your own integration, create a free account at kopra.dev and follow the steps above.