Most AI integrations stop at chat. Chat with a bot. Chat with a PDF. Chat with a bot that chats with other bots. It’s everywhere—and mostly unhelpful.
That’s why I loved Steve Sanderson’s NDC Oslo talk, "How to add genuinely useful AI to your web app (not just chatbots)," where he demoed Microsoft SmartComponents—AI-powered UI components for Blazor apps. One in particular stood out: Smart Paste.
Smart Paste automatically fills out forms using data from the user’s clipboard. Users can paste structured data into a form with a single click—no retyping or guesswork. It works with existing forms and simplifies a notoriously annoying task.
As someone in the React ecosystem, I couldn’t help but wonder: What would it take to build something like this in React?

This is a great example of AI reducing friction in real workflows. Instead of just embedding chat, it automates something mundane and meaningfully improves UX.
Turns out, due to React's flexible ecosystem, it’s very doable. Here’s how.
Hands-on learner? Check out the Smart Paste demo.
Planning the Smart Paste Build
We have a few core design goals:
-
Form-agnostic: It should work with any form structure.
-
Unobtrusive: Developers should be able to drop it in with minimal setup.
const SomeForm = () => {
return (
<form className="space-y-4">
<SmartPaste/>
<input name="name" type="text" placeholder="Name" />
<input name="title" type="text" placeholder="Title" />
<input name="location" type="text" placeholder="Location" />
</form>
)
}
When the button is clicked, it should:
-
Find the closest
<form>
-
Scrape the form fields to determine what data is expected
-
Send those fields and the clipboard data to an LLM
-
Update the form with the results
Pretty straightforward on paper, but there are a few nuances when dealing with React.
Choosing the Tech Stack for an AI-Powered React Component
When most developers hear "React" and "AI" in the same sentence, their mind probably goes straight to Next.js and Vercel’s AI SDK—and with good reason.
Vercel has made AI integration into React apps (especially Next.js) super clean. With features like Server Actions and a polished AI SDK, we can build something like Smart Paste with minimal friction.
We'll use:
-
Next.js
-
Vercel AI SDK
-
Zod for schema validation and generation
While our example will use Next.js and Vercel’s tooling, the overall flow and architecture can be adapted to work in any React application.
Bootstrapping the App
To build the Smart Paste component, we’ll need a scaffold Next application which you can create by firing up the create-next-app
cli and accepting all the default options.
npx create-next-app@latest
Styling isn’t the emphasis for the build, so for the sake of clarity, all styles will be omitted. The demo at the end uses Tailwind.
Once bootstrapped, we can create a SmartPaste.tsx
file within the project and scaffold out a button component.
// SmartPaste.tsx
import { FC } from 'react'
const Button: FC = () => {
return <button>Smart Paste</button>
}
This button is pretty barebones—it’s not reusable and doesn’t accept props or even children—but that’s fine for now. Our focus is on the Smart Paste functionality, not crafting the perfect button component.
With the button in place, we can start walking through the three main steps we need to tackle before we integrate with the SDK.
1. Finding the Nearest Form
Thanks to the Web Element API, finding the nearest element to another is straightforward. Every element has a .closest()
method, which lets you find the closest ancestor that matches a given selector. So, to access the <form>
that our button was dropped into, we can simply call .closest('form')
on the button element.
There are a few ways to access a DOM node in React, with ref
being the most common. But since we’re handling this in an onClick
, we can skip the ref
and access the button directly through the event’s currentTarget
. The currentTarget
always refers to the element to which the event handler (our onClick
in this case) is bound.
// SmartPaste.tsx
import { FC, MouseEvent } from 'react'
const Button: FC = () => {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
const form = event.currentTarget.closest("form");
if (!form) {
return
}
};
return <button onClick={handleClick}>Smart Paste</button>
}
2. Scraping the Form Fields
Similarly, to get the elements of a form, we can use the .elements
property on the form element. This property returns all non-image controls within the form element. Meaning this function filters out a lot of the noise that can appear in forms, returning only:
-
<button>
-
<fieldset>
-
<input>
(with the exception of any whosetype
is"image"
) -
<object>
-
<output>
-
<select>
-
<textarea>
There is a section at the end where we will discuss some additional ways to handle other form elements like <select>
. To keep things simple, let’s focus on just <input>
, we’ll filter the rest out for now.
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
const form = event.currentTarget.closest("form");
if (!form) {
return
}
const elements = Array.from(form.elements).filter(
(element): element is HTMLInputElement => element.tagName === "INPUT"
);
};
Once we’ve gathered all the input elements, we need to transform them into a format that describes the shape of the data we want the LLM to return. Later on, we’ll use this structure to generate a Zod schema, which will plug into the AI SDK.
A Brief Aside on Implementation
This component is going to have a lot of magic, and as a consequence, we are going to have to make some assumptions and force the form to have some semblance of some shape. For the LLM to generate an object for us, we need to have some idea of what the data is. In the case of form elements, a simple solution is to read the name of each input; however, names are not required for forms to work, especially in React.
For this post, we will use name
s due to their simplicity. This means we expect every relevant input within the form to have a unique associated name
, not an unreasonable expectation, but something that does add friction to using this SmartPaste button.
Alternative approaches for a more robust Smart Paste could involve looking for what’s labeling the input since those are required for accessible forms and are more likely to be present—though even that isn’t guaranteed, and there’s no consistent way to access field labels.
Because of that, in practice, it’s much easier to pick a strategy and stick with it in your application rather than trying to build a universal form processor—which would be my recommendation for use outside the constraints of this post.
In our case, the information we care about is:
-
The name of the input, which we’ll use as the key in our object
-
The type of the input (e.g.
"text"
,"number"
), which helps define the expected data type for each key
Essentially we want:
<form className="space-y-4">
<input name="title" type="text" placeholder="Title" />
</form>
to become:
{
name: 'title',
type: 'text',
}
This will give us a blueprint we can use to validate and shape the data returned by the LLM. We can format our data by mapping over elements and grabbing the relevant information.
interface FormField {
name: string;
type: string;
}
const inputTypesToIgnore = ["button", "submit", "image", "reset", "file", "password"];
const getFormFields = (elements: HTMLInputElement[]): FormField[] => {
return elements
.filter(({ name, type }) => name && !inputTypesToIgnore.includes(type))
.map((element) => {
return {
name: element.name,
type: element.type || "text",
};
});
};
The keen-eyed amongst us might notice an additional .filter
we didn’t really discuss. Its purpose is to ensure the elements have a name and we don’t include buttons, file inputs, or passwords.
All together our Smart Paste looks like this:
// SmartPaste.tsx
import { FC, MouseEvent } from 'react'
interface FormField {
name: string;
type: string;
}
const inputTypesToIgnore = ["button", "submit", "image", "reset", "file", "password"];
const getFormFields = (elements: HTMLInputElement[]): FormField[] => {
return elements
.filter(({ name, type }) => name && !inputTypesToIgnore.includes(type))
.map((element) => {
return {
name: element.name,
type: element.type || "text",
};
});
};
const Button: FC = () => {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
const form = event.currentTarget.closest("form");
if (!form) {
return
}
const elements = Array.from(form.elements).filter(
(element): element is HTMLInputElement => element.tagName === "INPUT"
);
const formFields = getFormFields(elements)
};
return <button onClick={handleClick}>Smart Paste</button>
}
3. Sending Data to an LLM
With the field data derived, we can grab any information from the user’s clipboard using the clipboard
off the navigator API, and now we have all the parts we need to integrate the AI SDK.
const clipboardText = await navigator.clipboard.readText();
To keep our secrets safe—like the API key for our AI provider—we must ensure the AI-related functionality runs on the server. Otherwise, those secrets could be exposed to anyone using our app.
Thankfully, Next.js makes it easy to call server functions in the app
directory through a feature called Server Actions.
Server Actions are just async
functions that run on the server but can be invoked from both Server and Client Components, almost like calling a local function. Under the hood, Next.js handles the routing and ensures the function stays server-side.
To handle our clipboard data extraction and schema generation, we’ll create a Server Action that encapsulates both the schema-building logic and the AI integration.
But before we do that, let’s discuss the SmartPastebutton component—specifically, whether it should remain a Server Component or become a Client Component. This decision hinges on how we want to handle the loading and error states for the user experience.
In this post, we’ll take the path of least resistance and introduce some basic useState
values to track whether extraction is running and whether an error occurred. This means our SmartPaste component will become a Client Component—we haven’t implemented that yet, but we will soon.
It’s worth mentioning now because Server Actions are created differently in Server Components compared to Client Components. In Server Components, you can define a Server Action inline using the "use server"
directive.
import { FC } from 'react'
const Page: FC = () => {
// Server Action
async function create() {
'use server'
// Mutate data
}
return '...'
}
In Client Components, Server Actions must be imported from a separate file where the "use server"
directive sits at the top. So, after creating our server action in a new file (action.ts), it can be integrated into our SmartPaste component like any other async function.
// action.ts
"use server"
import { FormValue, FormField } from './SmartPaste'
export interface FormField {
name: string;
type: string;
}
// Don't worry about this type just yet
export type FormValue = string | boolean | string[] | null;
export const extractFormData = async (
text: string,
fields: FormField[]
): Promise<Record<string, FormValue>> => {
// TODO
}
// SmartPaste.tsx
"use client"
import { FC, MouseEvent } from 'react'
import { extractFormData, FormField } from './action'
const inputTypesToIgnore = ["button", "submit", "image", "reset", "file", "password"];
const getFormFields = (elements: HTMLInputElement[]): FormField[] => {
// Omitted for brevity
};
const Button: FC = () => {
// We made this async... we'll have to deal with that at some point
const handleClick = async (event: MouseEvent<HTMLButtonElement>) => {
const form = event.currentTarget.closest("form");
if (!form) {
return
}
const elements = Array.from(form.elements).filter(
(element): element is HTMLInputElement => element.tagName === "INPUT"
);
const formFields = getFormFields(elements)
const clipboardText = await navigator.clipboard.readText();
const extractedData = await extractFormData(clipboardText, formFields)
// ... TODO
};
return <button onClick={handleClick}>Smart Paste</button>
}
With this setup, we’re ready to integrate the AI SDK.
Integrating AI Smart Paste with AI SDK
Vercel’s AI SDK simplifies and standardizes the process of integrating different LLMs from various providers. Instead of manually handling the differences between APIs—like OpenAI’s models (gpt-4o
, gpt-3.5-turbo
, etc.) or Anthropic’s models (claude-3-sonnet
, claude-3.5-sonnet
, etc.)—the AI SDK abstracts that away. It provides a unified API that lets you interact with any supported model using a consistent interface. We’re not going to go through the entire SDK; for this post, we will be using OpenAi’s GPT-4-turbo model; however, if you’re following along and want to dig into the AI SDK more, check out their docs.
The main function we’re after in the AI SDK is generateObject
, which, as the docs put it, “generates a typed, structured object for a given prompt and schema using a language model.” This function can be used in a few different ways, but in our case, we’ll pass it a prompt and schema that definesthe shape of the object we want back.
import { z } from "zod";
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
const gpt = openai("gpt-4-turbo");
// object will be { name: string }
const { object } = await generateObject({
model: gpt,
prompt: "Create a random name.",
schema: z.object({
name: z.string()
})
});
The beauty of this is that the same schema is also used to validate the output (to avoid hallucinations), ensuring the returned object matches the expected structure. Currently, generateObject
only accepts a JSON Schema or a Zod schema; as previously mentioned, we’ll use Zod.
Adding the AI SDK to Smart Paste
To add in the AI SDK, install it, the provider you want (OpenAI in our case), and zod.
npm i zod ai @ai-sdk/openai
Then, we’ll need to do three things to get our data extracted:
-
Configure the model
-
Craft a prompt
-
Build the schema from the field data we’re passing into the action
Configuring OpenAI’s Model
Since we’re using a standard out-of-the-box model, configuring OpenAI is straightforward. We start by importing the OpenAI provider from @ai-sdk/openai
and calling it with the model name we want to use:
import { openai } from "@ai-sdk/openai";
const model = openai("gpt-4-turbo");
The last step is to provide your OpenAI API key via an environment variable. Add the following to your .env
file:
OPENAI_API_KEY=your-openai-key-here
You can create an API key by:
-
Create an OpenAI Account
-
Going to Settings in the top right
-
Select API keys on the left
-
Clicking “Create new secret key”
Remember that your API key is a secret! Do not share it with others or expose it in any client-side code (browsers, apps) or commit it to your git repo. API keys should be securely loaded from an environment variable or key management service on the server.
This setup is just the beginning. The AI SDK documentation provides more advanced configuration options, such as setting temperature, max tokens, or using streaming responses.
Craft a Prompt
Crafting a good prompt is all about clarity and intent—be specific about what you want the model to do, and provide enough context for it to understand the task. In our case, the task we’re asking the model to do is fairly straightforward for an LLM, and the schema will provide additional context. For our case, we will use the following prompt:
const prompt = `
Given the following unstructured text:
"""
${text}
"""
Extract the fields as a JSON object. If you do not have enough information to
fill out part of the object, leave it as undefined.
Only output a valid JSON object. Do not include explanation or comments.
`;
An improvement we could make down the line is to provide additional context about what the unstructured text is being parsed into—essentially, what the form represents. This extra information could help the LLM produce more accurate and relevant results. We will discuss some approaches that could be added at the end.
Building the Schema
The last step before we can complete the Server Action is to dynamically build a schema based on the form fields we're passing in. We’ll use Zod, a TypeScript-first schema validation library, to define the expected structure of the object the LLM should return.
To do this, we create a z.object()
where each key corresponds to an input field's name
, and the value is a Zod type based on the field’s type
. While there are around 25 input types in HTML, we can simplify things by ignoring types like button
, submit
, and file
(we did this when we created the field data in the component), and treating most others as strings. We'll map common types like number
, checkbox
, email
, and date
to more specific zod validators.
Here's how we do that:
const buildZodSchema = (fields: FormField[]) => {
const shape: ZodRawShape = {};
for (const field of fields) {
shape[field.name] = toZod(field);
}
return z.object(shape);
};
const toZod = ({ type }: FormField) => {
switch (type) {
case "number":
return z.coerce.number().optional();
case "checkbox":
return z.boolean().optional();
case "email":
return z.string().email().optional();
case "date":
return z.string().date().optional();
default:
return z.string().optional();
}
}
The Extraction Action
With these three pieces in place—the field extraction, schema generation, and model configuration—we’re now ready to extract the data. Since our function is relatively dynamic, we can ensure it only returns valid form values, filtering out anything we’ve explicitly chosen to ignore (like buttons or file inputs).
At this stage, the actual types of the fields aren’t critical—we're treating most values as strings unless otherwise specified. Later, when precise types do matter, we’ll rely on developer-provided hints to refine the schema and guide the model more accurately.
// action.ts
"use server";
import { z, ZodRawShape } from "zod";
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
interface FormField {
name: string;
type: string;
description?: string | null;
}
type FormValue = string | boolean | string[] | null;
const gpt = openai("gpt-4-turbo");
export const extractFormData = async (text: string, fields: FormField[]): Promise<Record<string, FormValue>> => {
const schema = buildZodSchema(fields);
const prompt = `
Given the following unstructured text:
"""
${text}
"""
Extract the fields as a JSON object. If you do not have enough information to
fill out part of the object, leave it as undefined.
Only output a valid JSON object. Do not include explanation or comments.
`;
const { object } = await generateObject({
model: gpt,
prompt,
schema,
});
return object;
};
const buildZodSchema = (fields: FormField[]) => {
const shape: ZodRawShape = {};
for (const field of fields) {
shape[field.name] = toZod(field);
}
return z.object(shape);
};
const toZod = ({ type }: FormField) => {
switch (type) {
case "number":
return z.coerce.number().optional();
case "checkbox":
return z.boolean().optional();
case "email":
return z.string().email().optional();
case "date":
return z.string().date().optional();
default:
return z.string().optional();
}
}
AI Smart Paste: Bringing It Together with UX
We’ve got the infrastructure in place, and our Server Action is wired up. Now, the last major step is to handle UX, like loading and error states, and use the extracted data to update the form.
Async Handling
In previous sections, we made our click handler async
so we could await
both the clipboard read and the Server Action call. Now, we need to handle the async state, specifically, showing a loading indicator and handling any errors that occur.
The simplest way to do this is by adding a couple of useState
values:
const LowKeyButton = () => {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error>()
const handle = (event: React.MouseEvent<HTMLButtonElement>) => {
setIsLoading(true)
setError(undefined)
try {
await someAsyncAction()
} catch(error) {
setError(error as Error)
} finally {
setIsLoading(false)
}
}
return <button onClick={handle}>{isLoading ? "Loading..." : "Smart Paste"}</button>;
}
That works just fine, but since this logic isn’t unique to SmartPaste
, we can abstract it into a reusable hook and keep our component focused on what it actually does.
const LowKeyButton = () => {
const { isExecuting, error, execute } = useExecuteAsync(
async (event: React.MouseEvent<HTMLButtonElement>) => {
await someAsyncAction();
}
);
return (
<button onClick={execute}>
{isExecuting ? "Loading..." : "Smart Paste"}
</button>
);
};
We’re going to use this hook approach in our build. There are a thousand ways to handle an async state in React; this is just one. The hook isn’t the focus of this post, so I won’t go deep into it, but here’s the basic idea. You pass it an async
function, and it wraps that function, forwarding any arguments and giving you state (isExecuting
and error
) to use in the UI.
function useExecuteAsync<TArgs extends unknown[]>(
fn: (...args: TArgs) => Promise<void>
): { isExecuting: boolean; error?: Error; execute: (...args: TArgs) => Promise<void> } {
const [isExecuting, setIsExecuting] = useState(false);
const [error, setError] = useState<Error>();
const execute = (...args: TArgs) => {
setIsExecuting(true);
setError(undefined);
return fn(...args)
.catch((err) => {
if (err instanceof Error) {
setError(err);
} else {
setError(new Error("Something went wrong"));
}
})
.finally(() => setIsExecuting(false));
};
return {
isExecuting,
error,
execute,
};
}
Updating the Form
In React, developers typically handle forms in one of two ways: either by controlling the input fields via component state and using that state on submission or by leaving the inputs uncontrolled and relying on the form’s submission event to read the values directly from the DOM. Previously, controlled inputswere the dominant approach to handling forms in the old React documentation.
But with the rise of server functions and modern React form hooks, we’re seeing a resurgence of uncontrolled forms. This is awesome since it aligns with progressive enhancement principles and how the rest of the web has handled forms for decades.
Uncontrolled forms
In the uncontrolled form paradigm, the data lives in the DOM, not in React state. So for our Smart Paste component to update the form, we need to interact directly with the DOM.
Normally, React discourages this. And for good reason. Direct DOM manipulation can cause inconsistencies between the UI and React’s virtual DOM. But in this case, it’s a one-off exception: since React isn’t tracking the form state, there’s nothing to fall out of sync. So, as long as we’re careful, we can get away with it.
Thanks to our schema-building endeavors, we already have a list of elements we’ll need to update. All we need to do is loop over the elements and reconcile them with the list, checking their type to ensure we set the correct property on the element.
const updateUncontrolledForm = (elements: HTMLInputElement[], extractedFormValues: Record<string, FormValue>): void => {
for (const element of elements) {
if (element.name in extractedFormValues) {
const value = extractedFormValues[element.name];
if (element.type === "checkbox") {
element.checked = Boolean(value);
} else {
element.value = String(value);
}
}
}
};
Controlled forms
In a controlled form paradigm, the developer controls the form's state, whether through useState
or a form library like react-hook-form
. For Smart Paste to work in this setup, we need a way to update that state directly. That means tweaking our component API slightly to accept a callback, which we’ll invoke once the extracted data is available.
But introducing a callback means developers will be handling our data in their code—which brings up another challenge: typing. Our component is intentionally generic, designed to work across different forms with varying shapes. That flexibility makes it harder to provide the strong, specific typings you'd want in a truly type-safe integration.
The way around this is to leverage TypeScript’s generics and make our Smart Paste button a genericcomponent. This allows us to type the onExtracted
callback to match the expected shape of the extracted data.
We can default the type argument for our uncontrolled cases, but to make this work with stricter typing, developers must explicitly pass a type argument to the component if they want full type safety for their specific form shape. It’s an extra step, but it’s a reasonable tradeoff for integrating the kind of "magic" this component provides into a strongly typed codebase.
type SmartPasteButtonProps<FormValues extends Record<string, FormValue>> = {
onExtracted?: (input: FormValues) => void;
};
export function SmartPaste<FormValues extends Record<string, FormValue> = Record<string, FormValue>>({
onExtracted,
}: SmartPasteButtonProps<FormValues>) {
// implementation detail
}
To get the types working
import { FC, useState, ChangeEvent } from 'react'
import SmartPaste from './SmartPaste'
const Form: FC = () => {
const [form, setForm] = useState({ name: "", title: "", location: "" });
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setForm((previous) => ({...previous, [event.target.name]: event.target.value }))
}
return (
<form>
<SmartPaste<typeof form> onExtracted={setForm} />
{/** or */}
<SmartPaste onExtracted={(f: typeof form) => setForm(f)} />
<input name="name" placeholder="Name" value={form.name} onChange={handleChange} />
<input name="title" placeholder="Title" value={form.title} onChange={handleChange} />
<input name="location" placeholder="Location" value={form.location} onChange={handleChange}/>
<input type="submit" />
</form>
)
}
Supporting Both
To support both cases, we need to conditionally call either the onExtracted
prop or the updateUncontrolledForm
function, depending on which setup is called for. We can do this by checking the existence of the onExtracted
prop. If it's there, the developer is controlling the form; otherwise, we’re in an uncontrolled form setup.
// SmartPaste.tsx
// Additional functions ommitted for brevity
type SmartPasteButtonProps<FormValues extends Record<string, FormValue>> = {
onExtracted?: (input: FormValues) => void;
};
export function SmartPaste<FormValues extends Record<string, FormValue> = Record<string, FormValue>>({
onExtracted,
}: SmartPasteButtonProps<FormValues>) {
const { isExecuting, error, execute } = useExecuteAsync(async (e: React.MouseEvent<HTMLButtonElement>) => {
const form = e.currentTarget.closest("form");
if (!form) throw new Error("No form found.");
const clipboardText = await navigator.clipboard.readText();
const elements = Array.from(form.elements).filter(
(element): element is HTMLInputElement => element.tagName === "INPUT"
);
const fields = getFormFields(elements);
// call server action
const extracted = await extractFormData(clipboardText, fields);
if (onExtracted) {
// You do have to cast here, c'est la vie
onExtracted(extracted as FormValues);
} else {
updateUncontrolledForm(elements, extracted);
}
});
return (
<div className="mb-4">
<button type="button" onClick={execute} disabled={isExecuting}>
{isExecuting ? "Smart Pasting…" : "Smart Paste"}
</button>
{error && <p>{error?.message}</p>}
</div>
);
}
Smart Paste in Action
With that, we’ve completed the first iteration of Smart Paste. While there’s plenty of room for improvement, we’ll cover those later. For now, let’s explore how Smart Paste can be integrated into some popular form libraries and patterns.
Using Smart Paste with Server Actions
Here’s how Smart Paste fits into a server action-based form in Next.js. This example shows how to connect the SmartPaste button and pass clipboard data into your form fields.
export default function ContactForm() {
const createContact = async (formData: FormData) => {
"use server";
console.log({
name: formData.get("name"),
title: formData.get("title"),
location: formData.get("location"),
});
};
return (
<div className="max-w-2xl mx-auto p-8">
<form className="space-y-6 bg-white p-6 shadow-md rounded-lg border" action={createContact}>
<div className="flex justify-between items-start">
<p className="text-xl font-black">Create Contact</p>
<SmartPasteButton />
</div>
<input name="name" placeholder="Name" className="w-full p-2 border" />
<input name="title" placeholder="Title" className="w-full p-2 border" />
<input name="location" placeholder="Location" className="w-full p-2 border" />
<button type="submit" className="p-2 rounded bg-black text-white">
Submit
</button>
</form>
</div>
);
}
Using Smart Paste with React Hook Form
This time, we’re using React Hook Form and passing the extracted Smart Paste values into
reset
. This simple pattern keeps your forms fully controlled and ergonomic.
"use client";
import { useForm } from "react-hook-form";
import { SmartPasteButton } from "../components/smart-paste/smart-paste";
export default function ContactForm() {
const { register, handleSubmit, reset } = useForm();
const onSubmit = async (data: Record<string, string>) => {
console.log({
name: data.name,
title: data.title,
location: data.location,
});
};
return (
<div className="max-w-2xl mx-auto p-8">
<form className="space-y-6 bg-white p-6 shadow-md rounded-lg border" onSubmit={handleSubmit(onSubmit)}>
<div className="flex justify-between items-start">
<p className="text-xl font-black">Create Contact</p>
<SmartPasteButton onExtracted={reset} />
</div>
<input {...register("name")} placeholder="Name" className="w-full p-2 border" />
<input {...register("title")} placeholder="Title" className="w-full p-2 border" />
<input {...register("location")} placeholder="Location" className="w-full p-2 border" />
<button type="submit" className="p-2 rounded bg-black text-white">
Submit
</button>
</form>
</div>
);
}
Want to see it in action? Check out a deployed version of the code:
This demo uses real API calls via the Vercel AI SDK and is rate-limited to avoid running up a bill. Please be mindful when testing!
Additional Considerations and Ways to Improve
There are several ways we can improve the current implementation of Smart Paste. In this section, we’ll highlight a few enhancements that stand out and offer suggestions for how they could be approached. These won’t be fully implemented, and we’ll intentionally skip over concerns like styling, UX, error handling, or making the component more reusable. The goal here is to stay focused on the core logic and AI-related improvements.
Adding Descriptions
As mentioned briefly in the prompting section, a good prompt is clear and gives the model some context. Our current prompt and schema are super generic without much input from the form itself or the developer. We could enhance our prompting by allowing the developer to add descriptions to the form and to the form elements through data tags. (The blazor components support this as well).
<form action={createContact} data-sp-description="A form to create business contacts">
<SmartPasteButton />
<input name="name" data-sp-description="The contacts first and last name" />
<button type="submit">Submit</button>
</form>
These descriptions could be pulled off the form and its elements in the beginning of our handler as we are creating the fields and fed into the zod schema.
const buildZodSchema = (fields: FormField[]) => {
const shape: ZodRawShape = {};
for (const field of fields) {
let base = toZod(field);
if (field?.description) {
base = base.describe(field.description);
}
shape[field.name] = base;
}
return z.object(shape);
};
and the form description can be used either in the prompt or to describe the schema
const { object } = await generateObject({
model: gpt,
prompt,
schemaDescription: "Description from the form"
schema,
});
Handling Additional Input Types
Currently, we’re only handling a subset of the possible form elements—most notably, we're not handling<select>
inputs. To further enhance Smart Paste, we can extend the logic to support additional element types.
For <select>
, there’s an extra layer of complexity: the form needs to know what the valid options are. Without that context, the LLM might generate a value that doesn’t match any of the actual choices, resulting in broken or invalid form data.
-
Include
<select>
in your elements filter -
Inside the
getFormFields
function, use the.options
property to derive the list of valid values for the select (MDN Link) -
In the
toZod
function, add a check forselect
elements and usez.enum([...])
to build a schema that enforces one of the allowed options.
Similar logic could be implemented to handle radio type <input>
s.
Improving AI Smart Paste
There are a few more enhancements worth mentioning. Streaming support could improve performance and responsiveness when dealing with large amounts of clipboard text. Additionally, supporting more than just text input—such as accepting files or images—would open the door to use cases like extracting data from resumes, invoices, or IDs. These features would require more advanced prompt engineering and a more rigorous model selection.
Have other thoughts and ideas?
Give us a shout on the Bitovi Community Discord. 👋
Conclusion
AI doesn’t have to live inside a chat bubble. As we’ve seen, it can enhance real-world user experiences in subtle, powerful ways like helping users fill out forms with just one click. Smart Paste is just one example of what it looks like to bring AI into the UI as a truly useful tool, not just a novelty.
What makes this interesting isn’t just the AI—it’s how we integrate it. By treating AI like an infrastructure layer instead of a destination, we unlock ways to embed it directly into workflows, respecting the shape of the app and the expectations of the user. Whether it’s through Server Actions, schema-based validation, or progressive enhancement, we’re building tools that feel native, not bolted on.
So if you’re building forms, tools, or products that take in structured data, think about how AI can augment that process—not replace it. And when you do, let us know. We’d love to see what you build.