Have you ever spent half a day chasing down Functions cannot be passed directly to Client Components because of a misplaced "use client"? I have. Even now, with React Server Components (RSC) being the default in Next.js 16, this single boundary decision still trips me up almost every week.
What changed for me after switching to Antigravity is that drawing this boundary has become a conversation with the code, not a guessing game I lose at compile time. In this article I'll share the workflow I actually use on my personal projects to keep server/client boundaries clean. I'll skip the textbook explanation and stick to the cases where I've genuinely been stuck.
Why "just slap on use client" doesn't actually work
Most beginner posts wrap up with "add use client to anything interactive". That's half right, and half a trap. The cases that really bite tend to fall into one of these four buckets.
- Passing a function from a Server Component into a Client Component: callbacks like
onSelectdefined in a server closure can't be serialized - Spreading
use clientso high it pulls the whole tree client-side: drop it on<Layout>and you've just discarded most of the RSC benefits - Confusing how Promises move between Server Actions and Client Components: whether you
awaiton the client or resolve on the server changes the semantics - Calling
headers()/cookies()inside a Client Component'suseEffect: it doesn't just fail, it sometimes only fails in production
The frustrating part is that these don't blow up the moment you write the code. They show up at build time, or only on the deployed preview. "Works locally, breaks on Vercel" is a quietly demoralizing experience.
Letting Antigravity be the boundary keeper
What I appreciate about Antigravity is that I can have a design conversation before writing code. I've started applying that to RSC boundary decisions too. The flow looks like this:
- Tell Antigravity the intent before writing the component: one or two lines describing the UI and what state it owns ("this component renders search results and owns the input field state")
- Ask Antigravity to propose a boundary: "where should each piece live — Server, Client, or Server Action?"
- Push back on the proposal: ask "why client?" or "why not a route handler?" and you'll get reasoning you can evaluate
Since I started doing this, the back-and-forth of adding and removing "use client" after the fact has nearly disappeared. The trick is making the boundary decision before you start typing.
A prompt template you can copy
I want to implement this component in Next.js 16 (App Router).
Requirements:
- /search shows a list of search results
- The query comes from the URL parameter ?q=
- Clicking a result opens a detail modal (the modal does its own fetch)
- Pagination is a "Load more" button
Constraints:
- Keep as much as possible as Server Components; minimize the client surface
- Don't use Server Actions; use a Route Handler for the API instead
What I want from you:
1. A file split (page.tsx / SearchList.tsx / DetailModal.tsx ...)
2. The directive to put at the top of each file ("use client" or none)
3. The props passed across each boundary, with types
By locking down the constraints and the output format up front, the proposals stay focused and reviewable.
Walkthrough: extracting a useState component in three steps
Here's the workflow I run most often. Suppose you're building a search page. The naive version looks like this:
// app/search/page.tsx — sends everything to the client
"use client";
import { useState } from "react";
export default function SearchPage({ searchParams }) {
const [query, setQuery] = useState(searchParams.q ?? "");
const [results, setResults] = useState([]);
// ... fetch and setResults
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>
</>
);
}Tell Antigravity "I want to keep as much as possible on the server" and it usually returns something like this:
// app/search/page.tsx — still a Server Component
import { SearchInput } from "./SearchInput";
import { ResultList } from "./ResultList";
export default async function SearchPage({ searchParams }: { searchParams: { q?: string } }) {
const query = searchParams.q ?? "";
// Resolve the fetch on the server
const results = query
? await fetch(`${process.env.API_BASE}/search?q=${encodeURIComponent(query)}`, { cache: "no-store" }).then(r => r.json())
: [];
return (
<>
<SearchInput initialQuery={query} />
<ResultList items={results} />
</>
);
}// app/search/SearchInput.tsx — only the input is client-side
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
export function SearchInput({ initialQuery }: { initialQuery: string }) {
const router = useRouter();
const params = useSearchParams();
const [value, setValue] = useState(initialQuery);
const [isPending, startTransition] = useTransition();
function submit() {
const next = new URLSearchParams(params);
next.set("q", value);
startTransition(() => router.push(`/search?${next.toString()}`));
}
return (
<form onSubmit={e => { e.preventDefault(); submit(); }}>
<input value={value} onChange={e => setValue(e.target.value)} />
<button type="submit" disabled={isPending}>Search</button>
</form>
);
}ResultList stays a Server Component because it's display-only. The client surface shrinks to roughly twenty lines of SearchInput. What you should expect: in DevTools, only SearchInput shows up as a hydration boundary, and the result list is plain HTML on the wire. Lighthouse's TBT drops noticeably compared to the naive version.
The detail I always make sure to ask for is useTransition. Without it the URL navigation feels sluggish, and yet beginner tutorials almost never mention it.
A common stumble: passing a value from a Server Action to a Client Component
A lot of people write this code, and a lot of people get stuck on it.
// ❌ This produces "Functions cannot be passed"
export default function Page() {
async function deleteItem(id: string) {
"use server";
await db.items.delete(id);
}
return <ItemList onDelete={deleteItem} />; // boom if ItemList is a Client Component
}It feels like it should work, but functions defined inside a Server Component cannot cross into a Client Component as props. Ask Antigravity and you'll typically get these two suggestions:
// ✅ Option 1: extract to actions.ts and import on the client
// app/items/actions.ts
"use server";
export async function deleteItem(id: string) {
await db.items.delete(id);
}
// app/items/ItemList.tsx
"use client";
import { deleteItem } from "./actions";
import { useTransition } from "react";
export function ItemList({ items }: { items: Item[] }) {
const [isPending, startTransition] = useTransition();
return items.map(item => (
<li key={item.id}>
{item.title}
<button onClick={() => startTransition(() => deleteItem(item.id))} disabled={isPending}>Delete</button>
</li>
));
}// ✅ Option 2: bind the id and submit via a form
import { deleteItem } from "./actions";
export function ItemRow({ item }: { item: Item }) {
const action = deleteItem.bind(null, item.id);
return (
<form action={action}>
<span>{item.title}</span>
<button type="submit">Delete</button>
</form>
);
}I lean on option 1 when I want optimistic updates, and option 2 for clean navigations. Tell Antigravity whether you want optimistic UI and it will often suggest useOptimistic alongside the split.
My personal pre-flight checklist
When I start a new page, I throw these questions at Antigravity first. This single habit eliminated almost all of my "rewrite the boundary later" cycles.
- Which elements on this page change state in response to user actions
- Of those, which can be expressed via URL params (if they can, the server can own them)
- Can data fetching live entirely on the server, or do I genuinely need SWR / TanStack Query on the client
- If I need auth (
cookies()/headers()), at which boundary do I read them - Should this form submit through a Server Action or a Route Handler (the place you check permissions changes)
A one-minute design review like this is, to me, the biggest practical win of building with an AI partner. Antigravity can still get the boundary wrong on its own, but feed it sharpened requirements and the proposals come back close to right.
Related setup and migration notes
When you're migrating to Next.js 16 or wrestling with editor-side configuration, these earlier articles may help.
- Antigravity × Next.js Framework Integration Guide
- Refactoring Large Codebases in Antigravity — A Practical Strategy
- Plan Mode vs. Fast Mode in Antigravity — A Practical Comparison
Closing thought
Server/Client boundary decisions become reflexive once you've made them a few hundred times — but the path to that fluency is exhausting. The smallest thing you can try today is this: before writing your next page, hand Antigravity the file-split decision before you type any code. Even that single change cut about two to three hours a week of "rewrite use client after the fact" work for me. The pattern that helps me most is treating Antigravity less like a code generator and more like a colleague reviewing your architecture diagram before a single file gets created. The proposals are not always right, but the conversation forces you to make boundary decisions explicit — and that explicitness is exactly what RSC asks of us.