ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-05-01Intermediate

Drawing the Server/Client Boundary with Antigravity — A Workflow That Stops Next.js 16 RSC From Tripping You Up

Drawing the line between Server and Client Components is still the trickiest part of Next.js 16. Here's the practical workflow I use with Antigravity to stop misplaced `use client` directives and serialization errors before they happen.

antigravity436react-server-componentsnextjs-16typescript27

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 onSelect defined in a server closure can't be serialized
  • Spreading use client so 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 await on the client or resolve on the server changes the semantics
  • Calling headers() / cookies() inside a Client Component's useEffect: 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:

  1. 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")
  2. Ask Antigravity to propose a boundary: "where should each piece live — Server, Client, or Server Action?"
  3. 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.

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.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-05-03
Building Idempotency Keys and Dedupe Stores in TypeScript with Antigravity
A production guide to designing idempotency keys and dedupe stores in TypeScript with Antigravity — covering Stripe webhook retries, Temporal replays, and the Cloudflare KV / Redis / Postgres trade-offs you actually need to choose between.
App Dev2026-04-30
Antigravity × Better Auth: Building a Modern TypeScript Auth Stack End-to-End
End the NextAuth fatigue with Better Auth. A practical Antigravity-driven guide that ships schema generation, OAuth, RBAC, and Passkeys with production-ready patterns.
App Dev2026-04-29
Composing Event-Driven AI Workflows with Antigravity and Inngest — A Production-Ready Implementation Guide
A hands-on production guide to wiring Antigravity's AI agents with Inngest. Cover idempotency, concurrency control, human-in-the-loop, retry classification, and observability with copy-pasteable code.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →