How to Implement a Bootstrap Loading Spinner Within a Button in Next.js
To implement a bootstrap loading spinner within a button in Next.js, create a lightweight CSS-only spinner component and conditionally render it inside a client component while tracking the asynchronous operation state with useState or useTransition.
Next.js leverages the full React ecosystem to handle UI interactions, making it straightforward to add loading indicators to buttons without importing heavy CSS frameworks. The vercel/next.js repository includes a reference implementation in the with-apivideo example that demonstrates how to build a pure-CSS spinner. By combining this approach with React state management, you can deliver immediate visual feedback during network requests or form submissions.
How the Pattern Works
The implementation relies on three core concepts: client-side hydration, state tracking, and conditional rendering. Unlike server components, interactive buttons must execute in the browser to respond to user clicks and update their appearance dynamically.
Client Component Requirement
The button must be a client component because it reacts to user interaction and updates its state. In the repository, this pattern is illustrated by the minimal button in test/production/app-dir/route-handler-manifest-size/app/components/Button.tsx. You must add the "use client" directive at the top of your component file to ensure Next.js hydrates this element in the browser rather than rendering it statically on the server.
State Management Strategy
A useState<boolean> hook (or useTransition for React 18) tracks whether the async call is pending. While the loading flag is true, the button disables user interaction and renders the spinner. This prevents duplicate submissions and provides clear visual feedback that the operation is in progress.
Creating the Spinner Component
The vercel/next.js repository provides a lightweight reference in examples/with-apivideo/components/Loader/index.tsx. This component uses a pure CSS approach that mimics the Bootstrap spinner without requiring the entire Bootstrap library.
Create a reusable spinner component using CSS modules:
// app/components/Spinner.tsx
"use client";
import styles from "./Spinner.module.css";
export default function Spinner() {
return <div className={styles.spinner} aria-hidden="true" />;
}
/* app/components/Spinner.module.css */
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid #fff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
The CSS implementation matches the border-spinner technique found in examples/with-apivideo/components/Loader/Loader.module.css, using only a few CSS rules for the border and rotation animation. Because the spinner is a pure CSS component, it works identically across server-rendered pages, static exports, or edge runtimes.
Building the Async Button
Combine the spinner with state logic to create a button that displays the loader during asynchronous operations. This pattern ensures accessibility and prevents interaction while processing.
Basic Implementation with useState
// app/components/AsyncButton.tsx
"use client";
import { useState } from "react";
import Spinner from "./Spinner";
type Props = {
onClick: () => Promise<void>;
children: React.ReactNode;
};
export default function AsyncButton({ onClick, children }: Props) {
const [loading, setLoading] = useState(false);
async function handleClick() {
setLoading(true);
try {
await onClick();
} finally {
setLoading(false);
}
}
return (
<button
onClick={handleClick}
disabled={loading}
aria-busy={loading}
aria-live="polite"
className="relative flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded"
>
{loading && <Spinner />}
<span>{children}</span>
</button>
);
}
Key implementation details:
- State tracking:
useStatetoggles theloadingboolean before and after the async operation. - Conditional rendering: The
<Spinner />only renders whenloadingistrue, inserting itself before the button label using flex layout. - Accessibility:
aria-busysignals to screen readers that the element is updating, whiledisabledprevents additional clicks.
Alternative with useTransition (React 18)
For React 18 and later, you can leverage useTransition to handle the pending state automatically without manual try/finally blocks:
// app/components/TransitionButton.tsx
"use client";
import { useTransition } from "react";
import Spinner from "./Spinner";
type Props = {
onClick: () => Promise<void>;
children: React.ReactNode;
};
export default function TransitionButton({ onClick, children }: Props) {
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(async () => {
await onClick();
});
}
return (
<button
onClick={handleClick}
disabled={isPending}
aria-busy={isPending}
className="relative flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded"
>
{isPending && <Spinner />}
<span>{children}</span>
</button>
);
}
useTransition automatically manages the isPending flag while the async update is queued, simplifying the state management logic.
Integration Example
Use the component in any Next.js page to provide feedback during form submissions or API calls:
// app/page.tsx
import AsyncButton from "@/app/components/AsyncButton";
export default function Page() {
async function submitForm() {
// Simulate async work (e.g., fetch, mutation)
await new Promise((resolve) => setTimeout(resolve, 1500));
}
return (
<main className="p-8">
<h1 className="mb-4 text-2xl">Submit Data</h1>
<AsyncButton onClick={submitForm}>Send Request</AsyncButton>
</main>
);
}
When the user clicks Send Request, the button immediately disables and displays the spinning indicator until the promise resolves, delivering the familiar Bootstrap-style feedback pattern.
Summary
- Client components are required for interactive buttons that change state based on user actions, as shown in
test/production/app-dir/route-handler-manifest-size/app/components/Button.tsx. - Pure CSS spinners provide lightweight loading indicators without external dependencies; reference
examples/with-apivideo/components/Loader/index.tsxfor the implementation pattern. - State management via
useStateoruseTransitioncontrols when the spinner appears and ensures the button remains disabled during operations. - Accessibility attributes like
aria-busyandaria-liveensure screen readers announce loading states to all users.
Frequently Asked Questions
Do I need to install Bootstrap to use this loading spinner?
No. The implementation uses a pure CSS approach found in examples/with-apivideo/components/Loader/Loader.module.css that replicates the Bootstrap visual style without importing the Bootstrap library. You only need a few lines of CSS for the border rotation animation.
Why must the button be a client component?
The button must be a client component because it responds to user interactions and updates its internal state (loading status) in the browser. Next.js requires the "use client" directive to hydrate these interactive elements on the client side, as demonstrated in the repository's test components.
How do I prevent users from clicking the button multiple times while loading?
Set the button's disabled attribute to true when the loading state is active. This HTML property prevents click events and visually indicates the inactive state. Combine this with aria-busy="true" to notify assistive technologies that the element is currently processing.
Can I use this spinner with Server Actions in Next.js?
Yes. When using Server Actions, wrap the action call in useTransition (React 18) or useState to track the pending state on the client. The spinner renders while the Server Action is resolving, even though the actual work happens on the server, because the client component monitors the transition state.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →