This guide covers how to fetch a form schema from Lynkow, render fields dynamically, validate input, submit data with anti-spam protection, and handle reCAPTCHA -- everything you need to add contact forms, surveys, and lead capture to your Next.js site.

Prerequisites

You should have the Lynkow SDK installed and a client initialized. If not, see Guide 1: Installation.

TypeScript
// lib/lynkow.ts
import { createClient } from 'lynkow'

export const lynkow = createClient({
  siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
  fetchOptions: {
    next: { revalidate: 60 },
  },
})

1. Fetch form schema

Retrieve a form's full definition by its slug. The slug is set when you create the form in the Lynkow admin.

TypeScript
const form = await lynkow.forms.getBySlug('contact')

Form response

TypeScript
{
  id: number
  name: string                  // "Contact Form"
  slug: string                  // "contact"

  // Field definitions
  schema: FormField[]

  // Display and behavior settings
  settings: {
    submitLabel: string         // "Send message"
    successMessage: string      // "Thank you! We'll get back to you soon."
    redirectUrl: string | null  // Optional redirect after submission
    doubleOptIn: boolean        // Whether email confirmation is required
  }

  // Anti-spam configuration
  honeypotEnabled: boolean
  recaptchaEnabled: boolean
  recaptchaSiteKey: string | null
}

2. Render form dynamically

Map form.schema fields to HTML inputs. Since the form schema is defined in the Lynkow admin, your frontend renders whatever fields are configured -- no code changes needed when fields are added or removed.

tsx
'use client'

import { useEffect, useState } from 'react'
import { lynkow } from '@/lib/lynkow'
import type { Form, FormField } from 'lynkow'

export function DynamicForm({ slug }: { slug: string }) {
  const [form, setForm] = useState<Form | null>(null)

  useEffect(() => {
    lynkow.forms.getBySlug(slug).then(setForm)
  }, [slug])

  if (!form) return <p>Loading form...</p>

  return (
    <form>
      {form.schema.map((field) => (
        <div
          key={field.name}
          className={`form-field form-field--${field.width}`}
        >
          <FormFieldRenderer field={field} />
        </div>
      ))}

      <button type="submit">{form.settings.submitLabel}</button>
    </form>
  )
}

3. Field type mapping

Each FormField has a type that determines which HTML element to render. Here is the complete mapping:

TypeScript
interface FormField {
  name: string
  type: 'text' | 'email' | 'tel' | 'url' | 'textarea' | 'number'
       | 'date' | 'select' | 'radio' | 'checkbox' | 'file' | 'hidden'
  label: string
  placeholder?: string
  required: boolean
  helpText?: string
  defaultValue?: string
  width: 'full' | 'half' | 'third'
  options?: Array<{ value: string; label: string }> | null
  validation?: FormFieldValidation | null
}

interface FormFieldValidation {
  minLength?: number
  maxLength?: number
  min?: number
  max?: number
  pattern?: string    // Regex pattern
  message?: string    // Custom error message
}

Field renderer component

tsx
// components/form-field-renderer.tsx
import type { FormField } from 'lynkow'

export function FormFieldRenderer({
  field,
  error,
}: {
  field: FormField
  error?: string
}) {
  const inputProps = {
    id: field.name,
    name: field.name,
    required: field.required,
    placeholder: field.placeholder || undefined,
    defaultValue: field.defaultValue || undefined,
  }

  return (
    <>
      {field.type !== 'hidden' && (
        <label htmlFor={field.name}>
          {field.label}
          {field.required && <span className="required"> *</span>}
        </label>
      )}

      {renderInput(field, inputProps)}

      {field.helpText && (
        <small className="help-text">{field.helpText}</small>
      )}

      {error && <span className="field-error">{error}</span>}
    </>
  )
}

function renderInput(
  field: FormField,
  props: Record<string, unknown>
) {
  switch (field.type) {
    case 'textarea':
      return (
        <textarea
          {...props}
          minLength={field.validation?.minLength}
          maxLength={field.validation?.maxLength}
          rows={5}
        />
      )

    case 'select':
      return (
        <select {...props}>
          <option value="">Select...</option>
          {field.options?.map((opt) => (
            <option key={opt.value} value={opt.value}>
              {opt.label}
            </option>
          ))}
        </select>
      )

    case 'radio':
      return (
        <fieldset>
          <legend className="sr-only">{field.label}</legend>
          {field.options?.map((opt) => (
            <label key={opt.value} className="radio-label">
              <input
                type="radio"
                name={field.name}
                value={opt.value}
                required={field.required}
                defaultChecked={field.defaultValue === opt.value}
              />
              {opt.label}
            </label>
          ))}
        </fieldset>
      )

    case 'checkbox':
      // Single checkbox (boolean) vs multi-select
      if (!field.options || field.options.length === 0) {
        return (
          <label className="checkbox-label">
            <input
              type="checkbox"
              name={field.name}
              required={field.required}
              defaultChecked={field.defaultValue === 'true'}
            />
            {field.label}
          </label>
        )
      }
      return (
        <fieldset>
          <legend className="sr-only">{field.label}</legend>
          {field.options.map((opt) => (
            <label key={opt.value} className="checkbox-label">
              <input
                type="checkbox"
                name={field.name}
                value={opt.value}
              />
              {opt.label}
            </label>
          ))}
        </fieldset>
      )

    case 'number':
      return (
        <input
          {...props}
          type="number"
          min={field.validation?.min}
          max={field.validation?.max}
        />
      )

    case 'hidden':
      return <input {...props} type="hidden" />

    case 'file':
      return <input {...props} type="file" />

    // text, email, tel, url, date
    default:
      return (
        <input
          {...props}
          type={field.type}
          minLength={field.validation?.minLength}
          maxLength={field.validation?.maxLength}
          pattern={field.validation?.pattern || undefined}
          title={field.validation?.message || undefined}
        />
      )
  }
}

Width classes (CSS)

The width field controls column layout:

CSS
.form-field {
  margin-bottom: 1rem;
}

.form-field--full {
  width: 100%;
}

.form-field--half {
  width: 50%;
}

.form-field--third {
  width: 33.333%;
}

/* Responsive grid */
@media (max-width: 640px) {
  .form-field--half,
  .form-field--third {
    width: 100%;
  }
}

4. Validation

Use the validation object on each field to enforce rules client-side before submission. Native HTML validation attributes handle most cases, but you can add custom JavaScript validation for more control.

TypeScript
function validateField(
  field: FormField,
  value: string
): string | null {
  if (field.required && !value.trim()) {
    return `${field.label} is required`
  }

  const v = field.validation
  if (!v) return null

  if (v.minLength && value.length < v.minLength) {
    return v.message || `Minimum ${v.minLength} characters`
  }

  if (v.maxLength && value.length > v.maxLength) {
    return v.message || `Maximum ${v.maxLength} characters`
  }

  if (v.min !== undefined && Number(value) < v.min) {
    return v.message || `Minimum value is ${v.min}`
  }

  if (v.max !== undefined && Number(value) > v.max) {
    return v.message || `Maximum value is ${v.max}`
  }

  if (v.pattern && !new RegExp(v.pattern).test(value)) {
    return v.message || 'Invalid format'
  }

  return null
}

function validateForm(
  fields: FormField[],
  data: Record<string, string>
): Record<string, string> {
  const errors: Record<string, string> = {}

  for (const field of fields) {
    const error = validateField(field, data[field.name] || '')
    if (error) {
      errors[field.name] = error
    }
  }

  return errors
}

5. Submit handler

Use lynkow.forms.submit() to send the form data. The SDK automatically injects honeypot anti-spam fields (_hp and _ts) -- you do not need to add them yourself.

TypeScript
const result = await lynkow.forms.submit('contact', {
  name: 'Jane Smith',
  email: '[email protected]',
  message: 'Hello, I have a question about your services.',
})

Response

TypeScript
{
  message: string           // "Submission received"
  status: 'success' | 'pending'  // 'pending' if double opt-in is enabled
  submissionId?: string     // ID of the created submission
}

6. Success and pending handling

Show different UI depending on whether the submission was immediately accepted or requires confirmation (double opt-in).

tsx
function SubmissionResult({
  result,
  form,
}: {
  result: { message: string; status: 'success' | 'pending' }
  form: Form
}) {
  if (result.status === 'pending') {
    return (
      <div className="submission-pending">
        <h3>Almost there!</h3>
        <p>
          We have sent a confirmation email. Please check your inbox
          and click the link to complete your submission.
        </p>
      </div>
    )
  }

  // If the form has a redirect URL, navigate there
  if (form.settings.redirectUrl) {
    window.location.href = form.settings.redirectUrl
    return null
  }

  return (
    <div className="submission-success">
      <h3>Thank you!</h3>
      <p>{form.settings.successMessage}</p>
    </div>
  )
}

7. reCAPTCHA integration

When a form has recaptchaEnabled: true, you need to load Google reCAPTCHA v3 and pass the token to forms.submit().

Load the reCAPTCHA script

tsx
// components/recaptcha-provider.tsx
'use client'

import Script from 'next/script'

export function RecaptchaProvider({
  siteKey,
  children,
}: {
  siteKey: string
  children: React.ReactNode
}) {
  return (
    <>
      <Script
        src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}
        strategy="lazyOnload"
      />
      {children}
    </>
  )
}

Get a token before submission

TypeScript
async function getRecaptchaToken(siteKey: string): Promise<string> {
  return new Promise((resolve, reject) => {
    window.grecaptcha.ready(() => {
      window.grecaptcha
        .execute(siteKey, { action: 'submit' })
        .then(resolve)
        .catch(reject)
    })
  })
}

Pass the token to submit

TypeScript
if (form.recaptchaEnabled && form.recaptchaSiteKey) {
  const recaptchaToken = await getRecaptchaToken(form.recaptchaSiteKey)

  await lynkow.forms.submit('contact', formData, {
    recaptchaToken,
  })
} else {
  await lynkow.forms.submit('contact', formData)
}

8. Error handling

Server-side validation errors are returned as a LynkowError with code VALIDATION_ERROR. Each error includes the field name and a human-readable message.

TypeScript
import { isLynkowError } from 'lynkow'

try {
  await lynkow.forms.submit('contact', formData)
} catch (error) {
  if (isLynkowError(error) && error.code === 'VALIDATION_ERROR') {
    // Map server errors to field-level errors
    const fieldErrors: Record<string, string> = {}
    error.details?.forEach((detail) => {
      if (detail.field) {
        fieldErrors[detail.field] = detail.message
      }
    })
    setErrors(fieldErrors)
  } else if (isLynkowError(error) && error.code === 'TOO_MANY_REQUESTS') {
    setGlobalError('Too many submissions. Please try again later.')
  } else {
    setGlobalError('Something went wrong. Please try again.')
  }
}

9. Complete working example

Here is a full, copy-pasteable React component that handles loading, validation, reCAPTCHA, submission, and error display.

tsx
// components/dynamic-form.tsx
'use client'

import { useEffect, useState, useCallback } from 'react'
import Script from 'next/script'
import { lynkow } from '@/lib/lynkow'
import { isLynkowError } from 'lynkow'
import type { Form, FormField } from 'lynkow'

// ------- Declarations for reCAPTCHA v3 -------
declare global {
  interface Window {
    grecaptcha: {
      ready: (cb: () => void) => void
      execute: (siteKey: string, options: { action: string }) => Promise<string>
    }
  }
}

// ------- Main Component -------

export function DynamicForm({ slug }: { slug: string }) {
  const [form, setForm] = useState<Form | null>(null)
  const [values, setValues] = useState<Record<string, string>>({})
  const [errors, setErrors] = useState<Record<string, string>>({})
  const [globalError, setGlobalError] = useState<string | null>(null)
  const [loading, setLoading] = useState(false)
  const [result, setResult] = useState<{
    message: string
    status: 'success' | 'pending'
  } | null>(null)

  // Fetch form schema
  useEffect(() => {
    lynkow.forms.getBySlug(slug).then((f) => {
      setForm(f)

      // Initialize default values
      const defaults: Record<string, string> = {}
      f.schema.forEach((field) => {
        if (field.defaultValue) {
          defaults[field.name] = field.defaultValue
        }
      })
      setValues(defaults)
    })
  }, [slug])

  // Update a field value
  const setValue = useCallback((name: string, value: string) => {
    setValues((prev) => ({ ...prev, [name]: value }))
    // Clear field error on change
    setErrors((prev) => {
      const next = { ...prev }
      delete next[name]
      return next
    })
  }, [])

  // Client-side validation
  const validate = useCallback((): boolean => {
    if (!form) return false

    const newErrors: Record<string, string> = {}

    for (const field of form.schema) {
      const value = values[field.name] || ''

      if (field.required && !value.trim()) {
        newErrors[field.name] = `${field.label} is required`
        continue
      }

      if (!value) continue

      const v = field.validation
      if (!v) continue

      if (v.minLength && value.length < v.minLength) {
        newErrors[field.name] =
          v.message || `Minimum ${v.minLength} characters`
      } else if (v.maxLength && value.length > v.maxLength) {
        newErrors[field.name] =
          v.message || `Maximum ${v.maxLength} characters`
      } else if (v.pattern && !new RegExp(v.pattern).test(value)) {
        newErrors[field.name] = v.message || 'Invalid format'
      }
    }

    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }, [form, values])

  // Submit
  const handleSubmit = useCallback(
    async (e: React.FormEvent) => {
      e.preventDefault()
      if (!form) return

      setGlobalError(null)

      if (!validate()) return

      setLoading(true)

      try {
        // Get reCAPTCHA token if needed
        let recaptchaToken: string | undefined
        if (form.recaptchaEnabled && form.recaptchaSiteKey) {
          recaptchaToken = await new Promise<string>((resolve, reject) => {
            window.grecaptcha.ready(() => {
              window.grecaptcha
                .execute(form.recaptchaSiteKey!, { action: 'submit' })
                .then(resolve)
                .catch(reject)
            })
          })
        }

        const submitResult = await lynkow.forms.submit(
          slug,
          values,
          recaptchaToken ? { recaptchaToken } : undefined
        )

        setResult(submitResult)

        // Redirect if configured
        if (
          submitResult.status === 'success' &&
          form.settings.redirectUrl
        ) {
          window.location.href = form.settings.redirectUrl
        }
      } catch (error) {
        if (isLynkowError(error) && error.code === 'VALIDATION_ERROR') {
          const fieldErrors: Record<string, string> = {}
          error.details?.forEach((detail) => {
            if (detail.field) {
              fieldErrors[detail.field] = detail.message
            }
          })
          setErrors(fieldErrors)
        } else if (
          isLynkowError(error) &&
          error.code === 'TOO_MANY_REQUESTS'
        ) {
          setGlobalError('Too many submissions. Please try again later.')
        } else {
          setGlobalError('Something went wrong. Please try again.')
        }
      } finally {
        setLoading(false)
      }
    },
    [form, slug, values, validate]
  )

  // ------- Render -------

  if (!form) {
    return <div className="form-loading">Loading form...</div>
  }

  // Show result after successful submission
  if (result) {
    if (result.status === 'pending') {
      return (
        <div className="form-result form-result--pending">
          <h3>Almost there!</h3>
          <p>
            We sent a confirmation email. Please check your inbox and
            click the link to complete your submission.
          </p>
        </div>
      )
    }

    return (
      <div className="form-result form-result--success">
        <h3>Thank you!</h3>
        <p>{form.settings.successMessage}</p>
      </div>
    )
  }

  return (
    <>
      {/* Load reCAPTCHA script if needed */}
      {form.recaptchaEnabled && form.recaptchaSiteKey && (
        <Script
          src={`https://www.google.com/recaptcha/api.js?render=${form.recaptchaSiteKey}`}
          strategy="lazyOnload"
        />
      )}

      <form onSubmit={handleSubmit} noValidate>
        <div className="form-fields">
          {form.schema.map((field) => (
            <div
              key={field.name}
              className={`form-field form-field--${field.width} ${
                errors[field.name] ? 'form-field--error' : ''
              }`}
            >
              {field.type !== 'hidden' && (
                <label htmlFor={field.name}>
                  {field.label}
                  {field.required && (
                    <span className="required"> *</span>
                  )}
                </label>
              )}

              <FieldInput
                field={field}
                value={values[field.name] || ''}
                onChange={(val) => setValue(field.name, val)}
              />

              {field.helpText && (
                <small className="help-text">{field.helpText}</small>
              )}

              {errors[field.name] && (
                <span className="field-error" role="alert">
                  {errors[field.name]}
                </span>
              )}
            </div>
          ))}
        </div>

        {globalError && (
          <div className="form-error" role="alert">
            {globalError}
          </div>
        )}

        <button type="submit" disabled={loading}>
          {loading ? 'Submitting...' : form.settings.submitLabel}
        </button>
      </form>
    </>
  )
}

// ------- Field Input -------

function FieldInput({
  field,
  value,
  onChange,
}: {
  field: FormField
  value: string
  onChange: (value: string) => void
}) {
  const baseProps = {
    id: field.name,
    name: field.name,
  }

  switch (field.type) {
    case 'textarea':
      return (
        <textarea
          {...baseProps}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          placeholder={field.placeholder || undefined}
          required={field.required}
          minLength={field.validation?.minLength}
          maxLength={field.validation?.maxLength}
          rows={5}
        />
      )

    case 'select':
      return (
        <select
          {...baseProps}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          required={field.required}
        >
          <option value="">Select...</option>
          {field.options?.map((opt) => (
            <option key={opt.value} value={opt.value}>
              {opt.label}
            </option>
          ))}
        </select>
      )

    case 'radio':
      return (
        <fieldset>
          {field.options?.map((opt) => (
            <label key={opt.value} className="radio-label">
              <input
                type="radio"
                name={field.name}
                value={opt.value}
                checked={value === opt.value}
                onChange={() => onChange(opt.value)}
                required={field.required}
              />
              {opt.label}
            </label>
          ))}
        </fieldset>
      )

    case 'checkbox':
      if (!field.options || field.options.length === 0) {
        return (
          <label className="checkbox-label">
            <input
              type="checkbox"
              name={field.name}
              checked={value === 'true'}
              onChange={(e) => onChange(String(e.target.checked))}
              required={field.required}
            />
            {field.label}
          </label>
        )
      }
      return (
        <fieldset>
          {field.options.map((opt) => {
            const selected = value.split(',').filter(Boolean)
            return (
              <label key={opt.value} className="checkbox-label">
                <input
                  type="checkbox"
                  name={field.name}
                  value={opt.value}
                  checked={selected.includes(opt.value)}
                  onChange={(e) => {
                    const next = e.target.checked
                      ? [...selected, opt.value]
                      : selected.filter((v) => v !== opt.value)
                    onChange(next.join(','))
                  }}
                />
                {opt.label}
              </label>
            )
          })}
        </fieldset>
      )

    case 'hidden':
      return <input {...baseProps} type="hidden" value={value} />

    case 'file':
      return (
        <input
          {...baseProps}
          type="file"
          onChange={(e) => {
            const file = e.target.files?.[0]
            if (file) onChange(file.name)
          }}
          required={field.required}
        />
      )

    // text, email, tel, url, number, date
    default:
      return (
        <input
          {...baseProps}
          type={field.type}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          placeholder={field.placeholder || undefined}
          required={field.required}
          minLength={field.validation?.minLength}
          maxLength={field.validation?.maxLength}
          min={field.validation?.min}
          max={field.validation?.max}
          pattern={field.validation?.pattern || undefined}
          title={field.validation?.message || undefined}
        />
      )
  }
}

Usage

Drop the component into any page:

tsx
// app/contact/page.tsx
import { DynamicForm } from '@/components/dynamic-form'

export default function ContactPage() {
  return (
    <div className="container">
      <h1>Contact Us</h1>
      <p>Fill out the form below and we will get back to you.</p>
      <DynamicForm slug="contact" />
    </div>
  )
}

Multiple forms on the same page work independently:

tsx
<DynamicForm slug="contact" />
<DynamicForm slug="newsletter" />
<DynamicForm slug="feedback" />

Anti-spam: how it works

The SDK handles anti-spam automatically. You do not need to add any hidden fields yourself.

Mechanism

How it works

Your responsibility

Honeypot

SDK injects a hidden _hp field (must be empty) and a _ts timestamp. Bots that fill the honeypot or submit too quickly are rejected.

None -- fully automatic

reCAPTCHA v3

When enabled on the form, you load the reCAPTCHA script and pass the token via options.recaptchaToken.

Load the script, get a token, pass it to submit()

Important: Do not use raw fetch() to submit forms. The honeypot fields (_hp, _ts) are injected by the SDK internally. If you bypass the SDK, submissions will be rejected as spam.


Cache behavior

Method

SDK internal TTL

forms.getBySlug()

10 minutes

forms.submit()

No cache

TypeScript
lynkow.forms.clearCache()