> For the complete Lynkow documentation index in agent-friendly format, see [llms.txt](/llms.txt).

# Dynamic Forms

**Publié le** : 2026-08-14
**Catégorie** : Features

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.

> **Reference** - This guide is a tutorial. For exhaustive, always-current signatures and field lists, see the auto-generated reference:
> 
> - SDK: [`FormsService`](/docs/sdk/services/sdk-forms-service)
> - API: [Storefront: Forms & Reviews](/docs/storefront-api/storefront-forms-reviews)

## Prerequisites

You should have the Lynkow SDK installed and a client initialized. If not, see [Guide 1: Installation](/docs/sdk/getting-started/quick-start).

```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

The examples below read these fields:

```typescript
{
  name: string                  // "Contact Form"
  schema: FormField[]           // Field definitions (see section 3)
  settings: {
    submitLabel: string         // "Send message"
    successMessage: string      // Shown after a successful submit
    redirectUrl: string | null  // Optional redirect after submission
  }
  recaptchaEnabled: boolean     // Whether reCAPTCHA is required (see section 7)
  recaptchaSiteKey: string | null
}
```

See the full `Form` shape in the [SDK Types reference](/docs/sdk/types).

---

## 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. These are the fields the renderer below reads:

```typescript
interface FormField {
  name: string
  type: 'text' | 'email' | 'tel' | 'url' | 'textarea' | 'number'
       | 'date' | 'select' | 'radio' | 'checkbox' | 'file' | 'hidden'
  label: string
  required: boolean
  width: 'full' | 'half' | 'third'
  options?: Array<{ value: string; label: string }> | null
  validation?: FormFieldValidation | null   // minLength, maxLength, min, max, pattern, message
}
```

The `type` union above is the complete set of field types; the renderer below turns each one into the right HTML element. See the full `FormField` and `FormFieldValidation` shapes (including `placeholder`, `helpText`, and `defaultValue`) in the [SDK Types reference](/docs/sdk/types).

### 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: 'jane@example.com',
  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()
```