Skip to content
Drivn
5 min read

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.

Install the Button in a Next.js 16 project

Drivn has no runtime package to install — the CLI writes the component source into your repository and walks away. From the root of your Next.js 16 project run npx drivn add button. The CLI asks once where components live, defaulting to src/components/ui/, and writes button.tsx there. The file imports React, the local cn class-merge helper, and one icon — Loader2 from lucide-react for the loading spinner — so confirm that package is in your project. Everything else is Tailwind classes collected in a styles object at the top of the file. The CLI reference covers custom directories and batch installs, and the installation guide lists project prerequisites. After the file lands it is yours; Drivn releases never overwrite it.

1# from the root of your Next.js 16 project
2npx drivn add 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'
2import { Button } from '@/components/ui/button'
3
4async function subscribe(formData: FormData) {
5 'use server'
6 // persist the email
7}
8
9export 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'
3import { useFormStatus } from 'react-dom'
4import { Button } from '@/components/ui/button'
5
6export 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'
3import { Button } from '@/components/ui/button'
4
5export 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}
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

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.