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.
// 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.
const form = await lynkow.forms.getBySlug('contact')Form response
{
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.
'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:
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
// 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:
.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.
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.
const result = await lynkow.forms.submit('contact', {
name: 'Jane Smith',
email: '[email protected]',
message: 'Hello, I have a question about your services.',
})Response
{
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).
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
// 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
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
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.
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.
// 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:
// 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:
<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 | None -- fully automatic |
reCAPTCHA v3 | When enabled on the form, you load the reCAPTCHA script and pass the token via | Load the script, get a token, pass it to |
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 |
|---|---|
| 10 minutes |
| No cache |
lynkow.forms.clearCache()