Next.js JSON Form
A contact form built with Next.js App Router that uses a server action to validate input and persist submissions to a local JSON file. React 19’s useActionState handles form state — no useState needed.
How it works
The page is a server component that reads contacts.json on every request and renders the list. Below the list sits a client component wrapping a plain <form>. When the user submits, the browser calls a server action that validates the fields, appends the new contact to the JSON file, and calls revalidatePath so Next.js re-renders the page with fresh data.
nextjs-json-form/
├── data/contacts.json ← JSON "database" (seeded with one entry)
├── app/layout.tsx ← root layout (minimal)
├── app/page.tsx ← server component: reads JSON, renders list + form
├── app/actions.ts ← server action: validates, writes JSON, revalidates
└── app/contact-form.tsx ← client component: useActionState + isPendingdata/contacts.json
Seed file with one entry so the list isn’t empty on first load. The server action appends to this array.
[
{
"id": 1,
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "First entry in the guestbook."
}
]app/layout.tsx
Minimal root layout — just the HTML shell and the system font stack.
import type { ReactNode } from 'react'
export const metadata = { title: 'Next.js JSON Form' }
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 480, margin: '2rem auto' }}>
{children}
</body>
</html>
)
}app/actions.ts
'use server' makes every export in this file a server action. The function receives the previous FormState plus the FormData submitted by the browser. It validates, reads the JSON file, appends the new contact, writes it back, and calls revalidatePath('/') so the page re-renders with the updated list.
'use server'
import { readFileSync, writeFileSync } from 'fs'
import { revalidatePath } from 'next/cache'
import path from 'path'
export type Contact = {
id: number
name: string
email: string
message: string
}
export type FormState = {
error?: string
success?: boolean
}
const DATA_PATH = path.join(process.cwd(), 'data', 'contacts.json')
function readContacts(): Contact[] {
const raw = readFileSync(DATA_PATH, 'utf-8')
return JSON.parse(raw)
}
function writeContacts(contacts: Contact[]) {
writeFileSync(DATA_PATH, JSON.stringify(contacts, null, 2))
}
export async function addContact(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
const name = formData.get('name') as string | null
const email = formData.get('email') as string | null
const message = formData.get('message') as string | null
if (!name?.trim() || !email?.trim() || !message?.trim()) {
return { error: 'All fields are required.' }
}
const contacts = readContacts()
const nextId = contacts.length > 0 ? Math.max(...contacts.map((c) => c.id)) + 1 : 1
contacts.push({ id: nextId, name: name.trim(), email: email.trim(), message: message.trim() })
writeContacts(contacts)
revalidatePath('/')
return { success: true }
}app/page.tsx
This is a server component — it runs on the server and can call readFileSync directly. It reads the JSON file, maps the contacts into a list, and renders the client ContactForm below.
import { readFileSync } from 'fs'
import path from 'path'
import type { Contact } from './actions'
import { ContactForm } from './contact-form'
const DATA_PATH = path.join(process.cwd(), 'data', 'contacts.json')
export default function HomePage() {
const contacts: Contact[] = JSON.parse(readFileSync(DATA_PATH, 'utf-8'))
return (
<main>
<h1>Contacts</h1>
{contacts.length > 0 ? (
<ul>
{contacts.map((c) => (
<li key={c.id}>
<strong>{c.name}</strong> ({c.email})<br />
{c.message}
</li>
))}
</ul>
) : (
<p>No contacts yet.</p>
)}
<hr style={{ margin: '2rem 0' }} />
<ContactForm />
</main>
)
}app/contact-form.tsx
'use client' marks this as a client component so it can use hooks. useActionState takes the server action and an initial state, returning [state, formAction, isPending]. The formAction is passed straight to <form action={...}> — React handles the submission, serialization, and state update. When state.success is true, the form was submitted and the page has already re-rendered with the new contact. isPending disables the button while the action runs.
'use client'
import { useActionState, useRef, useEffect } from 'react'
import { addContact, type FormState } from './actions'
const initialState: FormState = {}
export function ContactForm() {
const [state, formAction, isPending] = useActionState(addContact, initialState)
const formRef = useRef<HTMLFormElement>(null)
useEffect(() => {
if (state.success) {
formRef.current?.reset()
}
}, [state])
return (
<form ref={formRef} action={formAction}>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="name">Name</label><br />
<input id="name" name="name" required style={{ width: '100%', padding: '0.4rem' }} />
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email">Email</label><br />
<input id="email" name="email" type="email" required style={{ width: '100%', padding: '0.4rem' }} />
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="message">Message</label><br />
<textarea id="message" name="message" rows={4} required style={{ width: '100%', padding: '0.4rem' }} />
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Add Contact'}
</button>
{state.error && <p style={{ color: 'red', marginTop: '0.5rem' }}>{state.error}</p>}
{state.success && <p style={{ color: 'green', marginTop: '0.5rem' }}>Contact added!</p>}
</form>
)
}Run it
npx create-next-app@latest nextjs-json-form --ts --app --no-tailwind --no-eslint --no-src-dir
cd nextjs-json-form
mkdir data
# create the files above: data/contacts.json, app/layout.tsx, app/page.tsx, app/actions.ts, app/contact-form.tsx
npm run devOpen http://localhost:3000 , fill out the form, and watch the contact appear in the list. Check data/contacts.json to see it persisted.