Single-Page Applications with Next.js
Next.js fully supports building Single-Page Applications (SPAs).
This includes fast route transitions with prefetching, client-side data fetching, using browser APIs, integrating with third-party client libraries, creating static routes, and more.
If you have an existing SPA, you can migrate to Next.js without large changes to your code. Next.js then allows you to progressively add server features as needed.
What is a Single-Page Application?
The definition of a SPA varies. We’ll define a “strict SPA” as:
- Client-side rendering (CSR): The app is served by one HTML file (e.g.
index.html
). Every route, page transition, and data fetch is handled by JavaScript in the browser. - No full-page reloads: Rather than requesting a new document for each route, client-side JavaScript manipulates the current page’s DOM and fetches data as needed.
Strict SPAs often require large amounts of JavaScript to load before the page can be interactive. Further, client data waterfalls can be challenging to manage. Building SPAs with Next.js can address these issues.
Why use Next.js for SPAs?
Next.js can automatically code split your JavaScript bundles, and generate multiple HTML entry points into different routes. This avoids loading unnecessary JavaScript code on the client-side, reducing the bundle size and enabling faster page loads.
The next/link
component automatically prefetches routes, giving you the fast page transitions of a strict SPA, but with the advantage of persisting application routing state to the URL for linking and sharing.
Next.js can start as a static site or even a strict SPA where everything is rendered client-side. If your project grows, Next.js allows you to progressively add more server features (e.g. React Server Components, Server Actions, and more) as needed.
Examples
Let's explore common patterns used to build SPAs and how Next.js solves them.
Using React’s use
within a Context Provider
We recommend fetching data in a parent component (or layout), returning the Promise, and then unwrapping the value in a client component with React’s use
hook.
Next.js can start data fetching early on the server. In this example, that’s the root layout — the entry point to your application. The server can immediately begin streaming a response to the client.
By “hoisting” your data fetching to the root layout, Next.js starts the specified requests on the server early before any other components in your application. This eliminates client waterfalls and prevents having multiple roundtrips between client and server. It can also significantly improve performance, as your server is closer (and ideally colocated) to where your database is located.
For example, update your root layout to call the Promise, but do not await it.
import { UserProvider } from './user-provider'
import { getUser } from './user' // some server-side function
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
let userPromise = getUser() // do NOT await
return (
<html lang="en">
<body>
<UserProvider userPromise={userPromise}>{children}</UserProvider>
</body>
</html>
)
}
While you can defer and pass a single Promise as a prop to a client component, we generally see this pattern paired with a React context provider. This enables easier access from client components with a custom React hook.
You can forward a Promise to the React context provider:
'use client';
import { use, createContext, ReactNode } from 'react';
type User = any;
type UserContextType = {
user: User;
};
const UserContext = createContext<UserContextType | null>(null);
export function UserProvider({
children,
userPromise,
}: {
children: ReactNode;
userPromise: Promise<User | null>;
}) {
let initialUser = use(userPromise);
return (
<UserContext.Provider value={{ user: initialUser }}>
{children}
</UserContext.Provider>
);
}
export function useUser(): UserContextType {
let context = use(UserContext);
if (context === null) {
throw new Error('useUser must be used within a UserProvider');
}
return context;
}
Finally, you can call the useUser()
custom hook in any client component:
'use client'
import { useUser } from './user-provider'
export function Profile() {
const { user } = useUser()
return '...'
}
The component that consumes the Promise (e.g. Profile
above) will be suspended. This enables partial hydration. You can see the streamed and prerendered HTML before JavaScript has finished loading.
SPAs with SWR
SWR is a popular React library for data fetching.
With SWR 2.3.0 (and React 19+), you can gradually adopt server features alongside your existing SWR-based client data fetching code. This is an abstraction of the above use()
pattern. This means you can move data fetching between the client and server-side, or use both:
- Client-only:
useSWR(key, fetcher)
- Server-only:
useSWR(key)
+ RSC-provided data - Mixed:
useSWR(key, fetcher)
+ RSC-provided data
For example, wrap your application with <SWRConfig>
and a fallback
:
import { SWRConfig } from 'swr'
import { getUser } from './user' // some server-side function
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<SWRConfig
value={{
fallback: {
// We do NOT await getUser() here
// Only components that read this data will suspend
'/api/user': getUser(),
},
}}
>
{children}
</SWRConfig>
)
}
Because this is a server component, getUser()
can securely read cookies, headers, or talk to your database. No separate API route is needed. Client components below the <SWRConfig>
can call useSWR()
with the same key to retrieve the user data. The component code with useSWR
does not require any changes from your existing client-fetching solution.
'use client'
import useSWR from 'swr'
export function Profile() {
const fetcher = (url) => fetch(url).then((res) => res.json())
// The same SWR pattern you already know
const { data, error } = useSWR('/api/user', fetcher)
return '...'
}
The fallback
data can be prerendered and included in the initial HTML response, then immediately read in the child components using useSWR
. SWR’s polling, revalidation, and caching still run client-side only, so it preserves all the interactivity you rely on for an SPA.
Since the initial fallback
data is automatically handled by Next.js, you can now delete any conditional logic previously needed to check if data
was undefined
. When the data is loading, the closest <Suspense>
boundary will be suspended.
SWR | RSC | RSC + SWR | |
---|---|---|---|
SSR data | |||
Streaming while SSR | |||
Deduplicate requests | |||
Client-side features |
SPAs with React Query
You can use React Query with Next.js on both the client and server. This enables you to build both strict SPAs, as well as take advantage of server features in Next.js paired with React Query.
Learn more in the React Query documentation.
Rendering components only in the browser
Client components are prerendered during next build
. If you want to disable prerendering for a client component and only load it in the browser environment, you can use next/dynamic
:
import dynamic from 'next/dynamic'
const ClientOnlyComponent = dynamic(() => import('./component'), {
ssr: false,
})
This can be useful for third-party libraries that rely on browser APIs like window
or document
. You can also add a useEffect
that checks for the existence of these APIs, and if they do not exist, return null
or a loading state which would be prerendered.
Shallow routing on the client
If you are migrating from a strict SPA like Create React App or Vite, you might have existing code which shallow routes to update the URL state. This can be useful for manual transitions between views in your application without using the default Next.js file-system routing.
Next.js allows you to use the native window.history.pushState
and window.history.replaceState
methods to update the browser's history stack without reloading the page.
pushState
and replaceState
calls integrate into the Next.js Router, allowing you to sync with usePathname
and useSearchParams
.
'use client'
import { useSearchParams } from 'next/navigation'
export default function SortProducts() {
const searchParams = useSearchParams()
function updateSorting(sortOrder: string) {
const params = new URLSearchParams(searchParams.toString())
params.set('sort', sortOrder)
window.history.pushState(null, '', `?${params.toString()}`)
}
return (
<>
<button onClick={() => updateSorting('asc')}>Sort Ascending</button>
<button onClick={() => updateSorting('desc')}>Sort Descending</button>
</>
)
}
Learn more about how routing and navigation work in Next.js.
Using Server Actions in client components
You can progressively adopt Server Actions while still using client components. This allows you to remove boilerplate code to call an API route, and instead use React features like useActionState
to handle loading and error states.
For example, create your first Server Action:
'use server'
export async function create() {}
You can import and use a Server Action from the client, similar to calling a JavaScript function. You do not need to create an API endpoint manually:
'use client'
import { create } from './actions'
export function Button() {
return <button onClick={() => create()}>Create</button>
}
Learn more about mutating data with Server Actions.
Static export (optional)
Next.js also supports generating a fully static site. This has some advantages over strict SPAs:
- Automatic code-splitting: Instead of shipping a single
index.html
, Next.js will generate an HTML file per route, so your visitors get the content faster without waiting for the client JavaScript bundle. - Improved user experience: Instead of a minimal skeleton for all routes, you get fully rendered pages for each route. When users navigate client side, transitions remain instant and SPA-like.
To enable a static export, update your configuration:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'export',
}
export default nextConfig
After running next build
, Next.js will create an out
folder with the HTML/CSS/JS assets for your application.
Note: Next.js server features are not supported with static exports. Learn more.
Migrating existing projects to Next.js
You can incrementally migrate to Next.js by following our guides:
If you are already using a SPA with the Pages Router, you can learn how to incrementally adopt the App Router.
Was this helpful?