✍️ Chapter 12: Server Actions & Mutations
Writing data without writing an API, forms that work before JavaScript loads, and UI that updates before the server replies.
📖 Plain English Explanation
To save a form in a typical React app you: build an API endpoint, write a fetch call, serialize the body, handle the response, manage loading state, manage error state, and then figure out how to refresh whatever data just changed.
A Server Action collapses all of that into a function.
async function createPost(formData: FormData) {
'use server'
await db.post.create({ data: { title: formData.get('title') as string } })
}
export default function Page() {
return (
<form action={createPost}>
<input name="title" />
<button>Create</button>
</form>
)
}
That's a complete, working, database-writing form. No API route. No fetch. No onSubmit.
Behind the scenes Next.js still creates an endpoint and still POSTs to it — it just generates all of that for you and gives you a function call instead of a network protocol.
There's a bonus that's easy to overlook: because it's a real <form action>, it works with JavaScript disabled. The browser does a native form POST, the server runs the action, and the page re-renders. Once JavaScript loads, the same form upgrades to a client-side submission with no page reload. Progressive enhancement, for free.
🏷️ Declaring an action
Two forms.
Inline in a Server Component
// app/posts/page.tsx
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export default function Page() {
async function createPost(formData: FormData) {
'use server'
const title = formData.get('title') as string
await db.post.create({ data: { title } })
revalidatePath('/posts')
}
return (
<form action={createPost}>
<input name="title" required />
<button>Create</button>
</form>
)
}
In a separate file (preferred)
// app/actions.ts
'use server' // ← applies to every export in the file
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.post.create({ data: { title } })
revalidatePath('/posts')
}
export async function deletePost(id: string) {
await db.post.delete({ where: { id } })
revalidatePath('/posts')
}
// app/actions.js
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createPost(formData) {
const title = formData.get('title')
await db.post.create({ data: { title } })
revalidatePath('/posts')
}
export async function deletePost(id) {
await db.post.delete({ where: { id } })
revalidatePath('/posts')
}
A separate file is better because Client Components can import actions from it. They can't define them.
Every Server Action must be async, even if it doesn't await anything.
📋 Forms with useActionState
The inline version has no way to show errors or a pending state. useActionState fixes that.
// app/actions.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
const schema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters'),
body: z.string().min(10, 'Body is too short'),
})
export type FormState = {
errors?: { title?: string[]; body?: string[] }
message?: string
success?: boolean
}
export async function createPost(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const parsed = schema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
})
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
try {
await db.post.create({ data: parsed.data })
} catch (error) {
console.error(error)
return { message: 'Could not save the post. Please try again.' }
}
revalidatePath('/posts')
return { success: true, message: 'Post created.' }
}
// app/actions.js
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
const schema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters'),
body: z.string().min(10, 'Body is too short'),
})
export async function createPost(prevState, formData) {
const parsed = schema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
})
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
try {
await db.post.create({ data: parsed.data })
} catch (error) {
console.error(error)
return { message: 'Could not save the post. Please try again.' }
}
revalidatePath('/posts')
return { success: true, message: 'Post created.' }
}
// app/posts/PostForm.tsx
'use client'
import { useActionState } from 'react'
import { createPost, type FormState } from '@/app/actions'
const initialState: FormState = {}
export function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState)
return (
<form action={formAction} className="space-y-4">
<div>
<input name="title" placeholder="Title" aria-describedby="title-error" />
{state.errors?.title && (
<p id="title-error" className="text-sm text-red-600">
{state.errors.title[0]}
</p>
)}
</div>
<div>
<textarea name="body" placeholder="Write something…" aria-describedby="body-error" />
{state.errors?.body && (
<p id="body-error" className="text-sm text-red-600">
{state.errors.body[0]}
</p>
)}
</div>
<button disabled={isPending}>{isPending ? 'Saving…' : 'Publish'}</button>
{state.message && (
<p className={state.success ? 'text-green-700' : 'text-red-600'}>
{state.message}
</p>
)}
</form>
)
}
// app/posts/PostForm.js
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions'
export function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, {})
return (
<form action={formAction} className="space-y-4">
<div>
<input name="title" placeholder="Title" />
{state.errors?.title && (
<p className="text-sm text-red-600">{state.errors.title[0]}</p>
)}
</div>
<div>
<textarea name="body" placeholder="Write something…" />
{state.errors?.body && (
<p className="text-sm text-red-600">{state.errors.body[0]}</p>
)}
</div>
<button disabled={isPending}>{isPending ? 'Saving…' : 'Publish'}</button>
{state.message && <p>{state.message}</p>}
</form>
)
}
Note the signature change: with useActionState, the action's first parameter is the previous state and formData is second.
Return errors, don't throw them. A thrown error bubbles to error.tsx, which unmounts the form and destroys everything the user typed.
⏳ useFormStatus for nested components
useActionState gives you isPending in the component that owns the form. If your submit button is a separate reusable component, use useFormStatus:
// app/components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus()
return (
<button disabled={pending}>
{pending ? 'Working…' : children}
</button>
)
}
// app/components/SubmitButton.js
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton({ children }) {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? 'Working…' : children}</button>
}
It must be rendered inside the <form> — it reads status from the nearest form context, not from a prop.
⚡ useOptimistic — update before the server replies
For actions that almost always succeed, waiting 300ms for a round-trip feels sluggish. useOptimistic shows the result immediately and reconciles when the real response arrives.
// app/todos/TodoList.tsx
'use client'
import { useOptimistic, useRef } from 'react'
import { addTodo } from '@/app/actions'
type Todo = { id: string; text: string; pending?: boolean }
export function TodoList({ todos }: { todos: Todo[] }) {
const formRef = useRef<HTMLFormElement>(null)
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state: Todo[], newText: string) => [
...state,
{ id: crypto.randomUUID(), text: newText, pending: true },
]
)
return (
<>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} className={todo.pending ? 'opacity-50' : ''}>
{todo.text}
</li>
))}
</ul>
<form
ref={formRef}
action={async (formData) => {
const text = formData.get('text') as string
addOptimistic(text) // appears instantly
formRef.current?.reset()
await addTodo(formData) // then the server catches up
}}
>
<input name="text" required />
<button>Add</button>
</form>
</>
)
}
// app/todos/TodoList.js
'use client'
import { useOptimistic, useRef } from 'react'
import { addTodo } from '@/app/actions'
export function TodoList({ todos }) {
const formRef = useRef(null)
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, newText) => [
...state,
{ id: crypto.randomUUID(), text: newText, pending: true },
]
)
return (
<>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} className={todo.pending ? 'opacity-50' : ''}>
{todo.text}
</li>
))}
</ul>
<form
ref={formRef}
action={async (formData) => {
const text = formData.get('text')
addOptimistic(text)
formRef.current?.reset()
await addTodo(formData)
}}
>
<input name="text" required />
<button>Add</button>
</form>
</>
)
}
If the action fails, React automatically discards the optimistic state and re-renders with the real data. You don't write rollback logic.
Use it for likely-to-succeed, low-stakes operations: likes, todos, toggles, reorderings. Don't use it for payments.
🔘 Actions outside forms
Bound arguments
bind pre-fills arguments — and unlike a hidden input, the value can't be tampered with by the client:
// app/posts/PostRow.tsx
import { deletePost } from '@/app/actions'
export function PostRow({ post }: { post: Post }) {
const deleteThisPost = deletePost.bind(null, post.id)
return (
<li>
{post.title}
<form action={deleteThisPost}>
<button>Delete</button>
</form>
</li>
)
}
// app/posts/PostRow.js
import { deletePost } from '@/app/actions'
export function PostRow({ post }) {
const deleteThisPost = deletePost.bind(null, post.id)
return (
<li>
{post.title}
<form action={deleteThisPost}>
<button>Delete</button>
</form>
</li>
)
}
Note this works from a Server Component — the action reference serializes across the boundary.
From an event handler
// app/components/LikeButton.tsx
'use client'
import { useTransition } from 'react'
import { toggleLike } from '@/app/actions'
export function LikeButton({ postId, liked }: { postId: string; liked: boolean }) {
const [isPending, startTransition] = useTransition()
return (
<button
disabled={isPending}
onClick={() => startTransition(() => toggleLike(postId))}
>
{liked ? '♥' : '♡'}
</button>
)
}
// app/components/LikeButton.js
'use client'
import { useTransition } from 'react'
import { toggleLike } from '@/app/actions'
export function LikeButton({ postId, liked }) {
const [isPending, startTransition] = useTransition()
return (
<button
disabled={isPending}
onClick={() => startTransition(() => toggleLike(postId))}
>
{liked ? '♥' : '♡'}
</button>
)
}
Wrapping in startTransition keeps the UI responsive and gives you a pending flag.
formAction on a button — two actions, one form
<form action={publishPost}>
<input name="title" />
<button>Publish</button>
<button formAction={saveDraft}>Save draft</button>
</form>
🔄 Refreshing data after a mutation
Writing to the database doesn't update what's on screen — the server-rendered data is now stale. You have four tools.
// app/actions.ts
'use server'
import { revalidatePath, revalidateTag, updateTag, refresh } from 'next/cache'
| Function | What it does | Use when |
|---|---|---|
revalidatePath(path) | Invalidates a specific route's cache | You know exactly which page changed |
revalidateTag(tag, profile) | Marks tagged data stale; readers see stale content while it refreshes | Blog posts, catalogs — a short delay is fine |
updateTag(tag) | Expires and immediately refreshes within the same request | The user must see their own change right away |
refresh() | Re-renders the current route on the client | Refresh visible UI after an action |
⚠️ Changed in Next.js 16
revalidateTagnow requires a second argument — acacheLifeprofile. The one-argument form is deprecated and produces a TypeScript error.ts// ❌ Next.js 15 revalidateTag('posts') // ✅ Next.js 16 revalidateTag('posts', 'max')
updateTag()andrefresh()are new in Next.js 16.updateTaggives read-your-writes semantics — the user sees their change immediately rather than stale data while a background revalidation runs.
The distinction that matters in practice:
// app/actions.ts
'use server'
import { revalidateTag, updateTag } from 'next/cache'
import { db } from '@/lib/db'
// A blog post edit — other readers can see stale content for a moment
export async function publishArticle(id: string) {
await db.article.update({ where: { id }, data: { published: true } })
revalidateTag(`article-${id}`, 'max')
}
// A user editing their own profile — they must see it change instantly
export async function updateProfile(userId: string, data: ProfileInput) {
await db.user.update({ where: { id: userId }, data })
updateTag(`user-${userId}`) // read-your-writes
}
// app/actions.js
'use server'
import { revalidateTag, updateTag } from 'next/cache'
import { db } from '@/lib/db'
export async function publishArticle(id) {
await db.article.update({ where: { id }, data: { published: true } })
revalidateTag(`article-${id}`, 'max')
}
export async function updateProfile(userId, data) {
await db.user.update({ where: { id: userId }, data })
updateTag(`user-${userId}`)
}
And refresh() when you just need the current screen redrawn:
'use server'
import { refresh } from 'next/cache'
export async function markAsRead(notificationId: string) {
await db.notification.update({
where: { id: notificationId },
data: { read: true },
})
refresh() // the unread badge in the header updates
}
Chapter 15 covers all of this in depth.
🔀 Redirecting after an action
// app/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const post = await db.post.create({
data: { title: formData.get('title') as string },
})
revalidatePath('/posts')
redirect(`/posts/${post.id}`) // must be last — it throws
}
Remember from Chapter 9: redirect() throws, so it must be outside any try/catch and nothing after it runs.
🔐 Security — the part people get wrong
A Server Action is a public HTTP endpoint. Next.js generates a URL for it and anyone can POST to it directly with curl. The fact that your UI only shows the button to admins means nothing.
Always authenticate and authorize inside the action
// app/actions.ts
'use server'
import { verifySession } from '@/lib/dal'
import { forbidden } from 'next/navigation'
import { db } from '@/lib/db'
export async function deletePost(postId: string) {
// 1. Authenticate
const session = await verifySession()
if (!session) return { error: 'Not signed in' }
// 2. Authorize — does THIS user own THIS post?
const post = await db.post.findUnique({
where: { id: postId },
select: { authorId: true },
})
if (!post) return { error: 'Not found' }
if (post.authorId !== session.userId && session.role !== 'admin') {
return { error: 'Not allowed' }
}
// 3. Only now, mutate
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
return { success: true }
}
// app/actions.js
'use server'
import { verifySession } from '@/lib/dal'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function deletePost(postId) {
const session = await verifySession()
if (!session) return { error: 'Not signed in' }
const post = await db.post.findUnique({
where: { id: postId },
select: { authorId: true },
})
if (!post) return { error: 'Not found' }
if (post.authorId !== session.userId && session.role !== 'admin') {
return { error: 'Not allowed' }
}
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
return { success: true }
}
Always validate the input
FormData comes from the client. Nothing stops someone sending arbitrary fields:
// ❌ mass assignment — a crafted request could set role: 'admin'
export async function updateUser(formData: FormData) {
const data = Object.fromEntries(formData)
await db.user.update({ where: { id: session.userId }, data })
}
// ✅ explicit allow-list via schema
const schema = z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
})
export async function updateUser(formData: FormData) {
const session = await verifySession()
if (!session) return { error: 'Not signed in' }
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }
await db.user.update({ where: { id: session.userId }, data: parsed.data })
}
Note the where: { id: session.userId } — the target comes from the session, not from user input. Never let the client tell you whose record to modify.
Don't put secrets in return values
Whatever an action returns is serialized to the browser. Return the minimum.
Rate limiting
Actions are endpoints; treat them like endpoints:
'use server'
import { headers } from 'next/headers'
import { rateLimit } from '@/lib/rate-limit'
export async function submitContactForm(prev: State, formData: FormData) {
const ip = (await headers()).get('x-forwarded-for') ?? 'unknown'
const { success } = await rateLimit(ip)
if (!success) return { message: 'Too many requests. Try again shortly.' }
// ...
}
📤 File uploads
FormData handles files natively:
// app/upload/page.tsx
import { uploadAvatar } from '@/app/actions'
export default function Page() {
return (
<form action={uploadAvatar}>
<input type="file" name="avatar" accept="image/*" required />
<button>Upload</button>
</form>
)
}
// app/actions.ts
'use server'
import { put } from '@vercel/blob'
import { verifySession } from '@/lib/dal'
const MAX_BYTES = 5 * 1024 * 1024
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp']
export async function uploadAvatar(formData: FormData) {
const session = await verifySession()
if (!session) return { error: 'Not signed in' }
const file = formData.get('avatar') as File
if (!file || file.size === 0) return { error: 'No file selected' }
if (file.size > MAX_BYTES) return { error: 'File must be under 5MB' }
if (!ALLOWED.includes(file.type)) return { error: 'Only JPEG, PNG, or WebP' }
const blob = await put(`avatars/${session.userId}`, file, { access: 'public' })
await db.user.update({
where: { id: session.userId },
data: { avatarUrl: blob.url },
})
revalidatePath('/settings')
return { success: true }
}
Validate size and type on the server. The accept attribute is a UI hint, not a control.
Note that file.type comes from the client too. For anything sensitive, check magic bytes rather than trusting the declared MIME type.
⚠️ Common Pitfalls
1. Forgetting 'use server'
Error: Functions cannot be passed directly to Client Components
The directive must be the first statement in the file (or the first statement in the function, for inline actions).
2. Throwing instead of returning for validation
// ❌ error.tsx replaces the form, user's input is gone
if (!parsed.success) throw new Error('Invalid')
// ✅
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }
3. Trusting IDs from the client
// ❌ anyone can POST any userId
export async function updateProfile(formData: FormData) {
const userId = formData.get('userId') as string
await db.user.update({ where: { id: userId }, data: {...} })
}
// ✅ target comes from the session
const session = await verifySession()
await db.user.update({ where: { id: session.userId }, data: {...} })
4. No auth check because the button is hidden
The button being hidden in the UI does not hide the endpoint. Check in the action, every time.
5. Forgetting to revalidate
// ❌ database updated, screen still shows old data
await db.post.create({ data })
// ✅
await db.post.create({ data })
revalidatePath('/posts')
6. revalidateTag with one argument
// ❌ TypeScript error in Next.js 16
revalidateTag('posts')
// ✅
revalidateTag('posts', 'max')
7. redirect() inside a try
Same trap as Chapter 9. Put it after the try, or use unstable_rethrow.
8. useFormStatus outside the form
// ❌ pending is always false
<SubmitButton />
<form action={action}>…</form>
// ✅
<form action={action}>
<SubmitButton />
</form>
9. Defining an action inside a Client Component
// ❌
'use client'
export function Form() {
async function save() { 'use server'; /* ... */ } // not allowed
}
Actions must be defined in Server Components or 'use server' modules. Client Components import them.
🎯 When & Why to Use
Server Action when:
✅ A form in your own app writes data
✅ Buttons that mutate (delete, like, toggle, reorder)
✅ You want progressive enhancement without effort
✅ You'd otherwise write an API route only your own UI calls
Route Handler instead when:
✅ An external service calls you (webhooks)
✅ A mobile app or third party needs the endpoint
✅ You need a non-POST method, or custom headers/status
✅ You're streaming a response
Client-side fetch instead when:
✅ Optimistic UI with complex client cache management
✅ You're already committed to TanStack Query / Redux Toolkit Query
🏋️ Mini Practice Problems
Problem 1: Security audit
Find every vulnerability:
'use server'
export async function updatePost(formData: FormData) {
const id = formData.get('id') as string
const data = Object.fromEntries(formData)
await db.post.update({ where: { id }, data })
return { post: await db.post.findUnique({ where: { id } }) }
}
There are at least four. Rewrite it.
Problem 2: Fix the form
'use client'
import { createUser } from './actions'
export function Form() {
const [state, formAction] = useActionState(createUser)
return (
<form action={formAction}>
<input name="email" />
<SubmitButton />
</form>
)
}
'use server'
export async function createUser(formData: FormData) {
const email = formData.get('email') as string
if (!email.includes('@')) throw new Error('Bad email')
await db.user.create({ data: { email } })
}
Three bugs. Name and fix them.
Problem 3: Choose the revalidation
Which of revalidatePath, revalidateTag, updateTag, or refresh for each?
- A. A user updates their display name and must see it in the header immediately
- B. An editor publishes an article; readers can see the old version for a few seconds
- C. Deleting a row from a table on the current page
- D. A price change that affects
/productsand/products/[id]
Problem 4: Build it
A comment form that:
- Validates: non-empty, max 500 chars
- Shows field errors without losing the typed text
- Disables the button while submitting
- Optimistically appends the comment, greyed out
- Requires login, checked in the action
- Rate-limits to 5 comments per minute per user
💼 Interview Notes
Common Questions
Q: What is a Server Action?
An async function marked 'use server' that runs on the server and can be called directly from a component — including as a <form action>. Next.js generates the endpoint and the client-side call for you.
Q: How do Server Actions give you progressive enhancement?
<form action={serverAction}> renders as a real HTML form. Without JavaScript, the browser performs a native POST and the server handles it. With JavaScript, React intercepts and submits without a page reload. Same code, both paths.
Q: Why return errors instead of throwing them?
A thrown error propagates to the nearest error.tsx, which unmounts the form and discards the user's input. Returning a serializable error object lets you render field-level messages while keeping the form intact.
Q: Are Server Actions secure by default? No. Each one is a public HTTP endpoint. You must authenticate, authorize, and validate input inside the action itself — UI-level gating means nothing to someone using curl.
Q: What's the difference between revalidateTag and updateTag?
revalidateTag marks data stale; the next readers get stale content while a fresh copy loads in the background. updateTag expires and refreshes in the same request, so the user who made the change sees it immediately — read-your-writes.
Q: How does useOptimistic work?
You give it the real state and a reducer. Calling the dispatch function applies an optimistic update immediately. When the action settles, React discards the optimistic state and re-renders from the real data — including automatically on failure, so there's no rollback code to write.
Q: When would you use a Route Handler instead of a Server Action? Webhooks, third-party or mobile clients, non-POST methods, custom status codes and headers, and streaming responses.
🏢 Asked at Companies
- Vercel: "Explain how a form submits before JavaScript has loaded, and what changes once it has."
- Stripe: "Here's a delete action. Attack it. Then fix it."
- Shopify: "Build an add-to-cart button with optimistic UI. What happens if the item is out of stock?"
- Linear: "Compare Server Actions to a REST endpoint plus TanStack Query. When is each the right call?"
📊 Visual Memory Aid
THE ROUND TRIP
<form action={createPost}>
│
│ 1. React serializes the FormData and POSTs
▼
┌──────────────────────────┐
│ SERVER │
│ 'use server' │
│ ├─ verify session ✅ │
│ ├─ validate input ✅ │
│ ├─ authorize ✅ │
│ ├─ mutate DB │
│ └─ revalidate │
└───────────┬──────────────┘
│ 2. returns state + fresh RSC payload
▼
UI re-renders with new data
(no manual refetch)
WHICH REVALIDATION
revalidatePath('/posts') one route
revalidateTag('x', 'max') tagged data, stale-while-revalidate
updateTag('x') tagged data, immediate (read-your-writes)
refresh() re-render current route on the client
SECURITY CHECKLIST
┌─ every Server Action ────────────────────┐
│ □ authenticated? (session exists) │
│ □ authorized? (this user, this record)│
│ □ input validated? (schema, not spread) │
│ □ target from session, not from formData │
│ □ rate limited? (public endpoint) │
│ □ return value free of secrets? │
└──────────────────────────────────────────┘
🎯 Key Takeaways
'use server'turns a function into a callable endpoint.<form action={fn}>gives you a working, progressively-enhanced form with no API route and nofetch.- Return errors, never throw them for validation. Throwing hands control to
error.tsxand wipes the form. - Every Server Action is a public endpoint. Authenticate, authorize, and validate inside it — and derive the target record from the session, not from client input.
- Mutating isn't enough; you must revalidate.
updateTag()for read-your-writes,revalidateTag(tag, profile)for stale-while-revalidate,revalidatePath()for a known route,refresh()for the current screen. useActionStatefor errors and pending,useFormStatusfor nested buttons,useOptimisticfor instant feedback — three hooks that cover essentially every form UX requirement.
Next Chapter: Route Handlers →
Practice: Build a full CRUD todo list with Server Actions — validation with returned errors, optimistic add and delete, a session check in every action, and correct revalidation. Then disable JavaScript in DevTools and confirm the forms still work.