Next.js Button — Server Actions & Client Boundaries
Use the Drivn Button in Next.js 16 — submit server actions without 'use client', show pending spinners with useFormStatus, zero runtime UI dependencies.
Buttons are where Next.js's server-client split gets real. The component itself is simple — a styled <button> — but the moment a server component passes it an onClick, the build greets you with "Event handlers cannot be passed to Client Component props". In the App Router, knowing where the 'use client' boundary belongs around a button matters more than any styling decision.
Drivn's Button keeps that decision simple because the component file carries no directive at all. There is no 'use client' in button.tsx, no hook, no browser API — just a forwardRef around a native <button> with a styles object covering variants, three sizes, and a rounded prop. Rendered without event handlers it stays a server component, which means a submit button inside a <form action={...}> ships zero client JavaScript while still triggering a Server Action.
This guide installs the Button in a Next.js 16 project, submits a Server Action from a server-rendered form, wires the loading prop to useFormStatus for a pending spinner, places the client boundary correctly for onClick handlers, and covers navigation buttons with next/link and useRouter. Every snippet matches the source the CLI installs. For the full prop table see the Button docs; for the head-to-head see Drivn vs shadcn/ui Button.
Submit a Server Action without 'use client'
Open button.tsx and you will find no 'use client' directive — the component is a forwardRef around a native <button> with zero hooks and zero browser APIs, so App Router pages render it on the server by default. That makes the most common button on the web, the form submit, a zero-JavaScript affair: give the Button type="submit" inside a <form action={...}> bound to a Server Action and the form posts without shipping a client bundle for it. The default variant renders bg-foreground text-background, flipping automatically between dark and light themes via design tokens. Variants, sizes, and the rounded prop all work identically on the server — styling is just class strings, and the full prop table lives in the Button docs.
1 // app/newsletter/page.tsx — server component, no 'use client' 2 import { Button } from '@/components/ui/button' 3 4 async function subscribe(formData: FormData) { 5 'use server' 6 // persist the email 7 } 8 9 export default function Page() { 10 return ( 11 <form action={subscribe}> 12 <input name="email" type="email" required /> 13 <Button type="submit">Subscribe</Button> 14 </form> 15 ) 16 }
Show a pending spinner with useFormStatus
A submit button should show feedback while the action runs. The Button ships a loading prop wired for exactly this: when true it renders a spinning Loader2 icon, hides any leftIcon or rightIcon, and disables the element — the source reads disabled={loading || disabled}, so double-submits are impossible. Pair it with React's useFormStatus hook in a small client component: the hook reports pending while the parent form's Server Action is in flight, and you forward that flag to loading. The boundary stays tiny — only the submit button becomes a client component; the form and the page around it stay on the server. Drop this SubmitButton into any Server Action form and the pending state wires itself. More finished patterns live on the Button examples page.
1 // submit-button.tsx — the only client boundary in the form 2 'use client' 3 import { useFormStatus } from 'react-dom' 4 import { Button } from '@/components/ui/button' 5 6 export function SubmitButton({ 7 children, 8 }: { children: React.ReactNode }) { 9 const { pending } = useFormStatus() 10 return ( 11 <Button type="submit" loading={pending}> 12 {children} 13 </Button> 14 ) 15 }
Where the client boundary goes for onClick
The moment a button needs an onClick, Next.js requires a client boundary — functions cannot cross the server-to-client divide, which is exactly what the build error "Event handlers cannot be passed to Client Component props" is telling you. The fix is never to mark the whole page 'use client'. Extract the interactive button into its own leaf file with the directive at the top, keep the page a server component, and import the leaf. The Button itself needs no changes — it spreads ...props onto the native element, so onClick, onMouseEnter, aria-* attributes, and everything else React.ButtonHTMLAttributes allows pass straight through, type-checked. The page keeps its server rendering; only the few lines that actually listen for events hydrate in the browser.
1 // copy-link-button.tsx — a leaf client component 2 'use client' 3 import { Button } from '@/components/ui/button' 4 5 export function CopyLinkButton() { 6 return ( 7 <Button 8 variant="outline" 9 size="sm" 10 onClick={() => 11 navigator.clipboard.writeText(window.location.href) 12 } 13 > 14 Copy link 15 </Button> 16 ) 17 }
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
Frequently asked questions
The component file itself carries no directive and renders fine in server components — it is a forwardRef around a native button with no hooks and no browser APIs. You only need a client boundary when you pass an event handler like onClick, because functions cannot serialize across the server-to-client divide. Submit buttons inside Server Action forms stay fully server-rendered and ship no client JavaScript.
A server component tried to pass a function — usually onClick — across the client boundary. Props crossing that boundary must be serializable, and functions are not. Extract the button plus its handler into a small file marked 'use client' and import that from your server page; the rest of the page keeps server rendering and ships no extra JavaScript for it.
Wrap the submit button in a client component that calls useFormStatus and forward the hook's pending flag to the Button's loading prop. While loading is true the component renders a spinning Loader2 icon, hides any left or right icon, and disables itself via disabled={loading || disabled}, so users cannot double-submit while the action is in flight.
Prefer a next/link for plain navigation — it prefetches, supports open-in-new-tab, and behaves like a real link for keyboards and screen readers. When the navigation is imperative, such as advancing a checkout flow after validation, call useRouter().push() inside the button's onClick in a client component. Avoid nesting the Button inside a Link element; interactive-inside-interactive markup is invalid HTML.

