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
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
The examples below read these fields:
{
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.
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. These are the fields the renderer below reads:
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.
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()