Type something to search...
Next.js Building single-page applications (SPAs)

Next.js Building single-page applications (SPAs)

There's a common assumption that "Next.js" and "SPA" describe opposite philosophies — one server-first, one client-first — and that adopting Next.js means abandoning the SPA model entirely. That's not actually true. Next.js fully supports building genuine Single-Page Applications, and more importantly, it fixes the two problems that make strict SPAs painful at scale: the large upfront JavaScript payload before anything's interactive, and the client-side data waterfalls that come from fetching everything after mount.

This article covers what a "strict SPA" actually means as a definition, why Next.js is a genuinely reasonable foundation for one, and the specific patterns for building SPA-shaped features — client-side data with use(), browser-only rendering, shallow routing, and Server-Action-backed mutations — inside the App Router.

Defining "strict SPA" precisely

The term gets used loosely, so it's worth pinning down what it actually means here: a strict SPA is served from one HTML document, with every route, page transition, and data fetch handled entirely by JavaScript running in the browser — no full-page reload ever, client-side JavaScript manipulating the current page's DOM directly rather than requesting a fresh document per route.

The two costs that come baked into that definition: a strict SPA typically needs to load a substantial amount of JavaScript before the page becomes interactive at all (since the entire routing and rendering logic lives client-side from the first paint onward), and managing client-side data waterfalls — one fetch triggering the component that triggers the next fetch, sequentially — becomes a genuinely hard problem to keep under control as an app grows.

Why Next.js specifically helps with both

Next.js automatically code-splits your JavaScript per route and generates multiple HTML entry points, rather than shipping one monolithic bundle regardless of which route a visitor actually landed on — directly addressing the "too much JS upfront" problem structurally, not through manual bundle-splitting configuration you'd have to build yourself.

next/link prefetches routes automatically as they enter the viewport, which gets you the fast, instant-feeling transitions a strict SPA is known for — while additionally persisting routing state to the actual URL, meaning links stay shareable and bookmarkable in a way a purely client-state-driven router often doesn't manage cleanly.

And the framework doesn't force an all-or-nothing choice up front: a project can start as a fully client-rendered SPA, then progressively adopt server features — Server Components, Server Actions — later, exactly where and when they become genuinely useful, rather than committing to one architecture irreversibly on day one.

Pattern: streaming server data into a Context Provider with use()

This is the pattern that replaces a client-side "fetch on mount" waterfall with something that starts before the client even begins rendering. The idea: start a data request in a Server Component — a parent, typically a layout — without awaiting it, and pass the still-pending promise down through context. A Client Component consumes it via React's use() API, since a Client Component can't await directly during render the way a Server Component can.

// app/layout.tsx
import { UserProvider } from "./user-provider";
import { getUser } from "./user";

export default function RootLayout({ children }: LayoutProps<"/">) {
  let userPromise = getUser(); // deliberately NOT awaited here

  return (
    <html lang="en">
      <body>
        <UserProvider userPromise={userPromise}>{children}</UserProvider>
      </body>
    </html>
  );
}
// app/user-provider.tsx
"use client";

import { createContext, useContext } from "react";

type User = { id: string; name: string };
const UserContext = createContext<Promise<User> | null>(null);

export function useUser() {
  const userPromise = useContext(UserContext);
  if (!userPromise)
    throw new Error("useUser must be used within a UserProvider");
  return userPromise;
}

export function UserProvider({
  children,
  userPromise,
}: {
  children: React.ReactNode;
  userPromise: Promise<User>;
}) {
  return (
    <UserContext.Provider value={userPromise}>{children}</UserContext.Provider>
  );
}
// app/profile.tsx
"use client";

import { use } from "react";
import { useUser } from "./user-provider";

export function Profile() {
  const userPromise = useUser();
  const user = use(userPromise); // suspends until the promise resolves
  return <p>{user.name}</p>;
}
// app/page.tsx
import { Suspense } from "react";
import { Profile } from "./profile";

export default function Page() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Profile />
    </Suspense>
  );
}

The genuinely important part of this sequencing: the request for getUser() starts on the server before the rest of the app even begins rendering, which is exactly what avoids the classic client waterfall — mount, then fetch, then wait, then render — collapsing it into "the data was already in flight by the time any client code ran."

Two things worth knowing before reaching for this broadly. First, refetching a promise set high in the tree re-runs the Server Component that originally created it — so for data only one specific subtree actually needs, place the provider on that subtree rather than defaulting to the root layout for everything, or you'll be re-running work higher up the tree than necessary. Second, if multiple components read the same underlying data within one request, wrap the fetching function in React's own cache() so they genuinely share a single call rather than each triggering an independent, redundant fetch.

And if a Client Component needs something more than simple one-shot fetching — focus revalidation, polling, deduplication across many call sites, optimistic mutations — that's exactly the point to reach for a proper client-data library like SWR or TanStack Query instead of hand-rolling more of this pattern yourself; the dedicated client-side data fetching articles elsewhere in this series cover both in depth.

Pattern: rendering something only in the browser

Client Components get prerendered during next build by default — which is usually exactly what you want, but becomes a real problem for a library that assumes it's always running in a genuine browser environment and reaches for window or document unconditionally, crashing during that prerender pass. next/dynamic with ssr: false is the fix, opting a specific component out of prerendering entirely and loading it purely client-side:

import dynamic from "next/dynamic";

const ClientOnlyComponent = dynamic(() => import("./component"), {
  ssr: false,
});

The alternative, for cases where you'd rather handle this inline instead of via a separate dynamic import: a useEffect checking whether the browser API you need actually exists, returning null or a loading placeholder (which does get safely prerendered) when it doesn't.

Pattern: shallow routing without full-page-reload URL updates

If you're migrating an existing SPA — Create React App, Vite — into Next.js, you likely already have code that updates URL state manually, without triggering Next.js's own file-system-based routing at all. The native browser History API — window.history.pushState and replaceState — works here exactly as it always has, and it integrates cleanly with the Next.js router's own state, syncing correctly with usePathname and useSearchParams:

// app/ui/sort-products.tsx
"use client";

import { useSearchParams } from "next/navigation";

export default function SortProducts() {
  const searchParams = useSearchParams();

  function updateSorting(sortOrder: string) {
    const urlSearchParams = new URLSearchParams(searchParams.toString());
    urlSearchParams.set("sort", sortOrder);
    window.history.pushState(null, "", `?${urlSearchParams.toString()}`);
  }

  return (
    <>
      <button onClick={() => updateSorting("asc")}>Sort Ascending</button>
      <button onClick={() => updateSorting("desc")}>Sort Descending</button>
    </>
  );
}

This updates the visible URL and browser history without any full navigation or page reload at all — genuinely useful for manual view transitions (a sort order, a filter selection) that shouldn't trigger Next.js's file-system routing machinery, particularly during an incremental migration where large parts of your existing SPA routing logic aren't ready to be rewritten yet.

Pattern: mutations that feel instant, with Server Actions

A strict SPA typically manages mutations entirely client-side — optimistic local state update, background API call, reconcile on response. Next.js can reproduce that same feel using Server Actions paired with React's own concurrent-UI primitives, without needing a hand-rolled client-side mutation layer at all.

At the simplest end, wrapping a Server Action call in a transition and surfacing its pending state:

// app/delete-post.tsx
"use client";

import { useTransition } from "react";
import { deletePost } from "./actions";

export function DeletePost({ id }: { id: string }) {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(() => deletePost(id))}
    >
      {isPending ? "Deleting…" : "Delete"}
    </button>
  );
}

For genuinely list-shaped state — a to-do list is the canonical example — combining useActionState with useOptimistic around one shared reducer is the more sophisticated pattern, and it's worth understanding why sharing the reducer matters, not just how to wire it up. The reducer itself is pure and framework-agnostic:

// app/todos-reducer.ts
export type Todo = { id: string; text: string; done: boolean };
export type TodoAction =
  | { type: "add"; id: string; text: string }
  | { type: "toggle"; id: string }
  | { type: "delete"; id: string };

export function todosReducer(todos: Todo[], action: TodoAction): Todo[] {
  switch (action.type) {
    case "add":
      return [...todos, { id: action.id, text: action.text, done: false }];
    case "toggle":
      return todos.map((t) =>
        t.id === action.id ? { ...t, done: !t.done } : t,
      );
    case "delete":
      return todos.filter((t) => t.id !== action.id);
    default:
      return todos;
  }
}

The Server Action applies that same reducer server-side, persists the result, and returns the authoritative next state:

// app/actions.ts
"use server";

import { db } from "./db";
import { todosReducer, type Todo, type TodoAction } from "./todos-reducer";

export async function saveTodos(
  todos: Todo[],
  action: TodoAction,
): Promise<Todo[]> {
  const next = todosReducer(todos, action);
  await db.saveTodos(next);
  return next;
}

And the client passes that identical reducer to useOptimistic, so the instant, optimistic client-side update and the eventual, authoritative server computation are guaranteed to agree — because they're literally running the same function, not two independently-written implementations of "the same logic" that could quietly drift apart over time:

// app/todo-list.tsx
"use client";

import { useActionState, useOptimistic, startTransition } from "react";
import { saveTodos } from "./actions";
import { todosReducer, type Todo, type TodoAction } from "./todos-reducer";

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [todos, dispatch, isPending] = useActionState(saveTodos, initialTodos);
  const [optimisticTodos, addOptimistic] = useOptimistic(todos, todosReducer);

  function runAction(action: TodoAction) {
    startTransition(() => {
      addOptimistic(action);
      dispatch(action);
    });
  }

  return (
    <>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => runAction({ type: "toggle", id: todo.id })}
            />
            {todo.text}
          </li>
        ))}
      </ul>
      {isPending && <p>Syncing to server…</p>}
    </>
  );
}

Every interaction updates the UI immediately via addOptimistic, while dispatch sends the same action to the server in the same transition — isPending gives you a subtle, non-blocking sync indicator, without the UI ever feeling like it's waiting on the network for the interaction itself to register.

The static export option

Beyond the patterns above, Next.js can also generate a fully static site — genuinely no server process at all — which has real advantages over a strict SPA specifically because of the automatic per-route code-splitting: instead of one index.html serving every route from the same bundle, you get an HTML file per route, so visitors get real content sooner rather than waiting on a shared client bundle regardless of which page they landed on, while client-side navigation between routes still feels exactly as instant and SPA-like as before.

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
};

export default nextConfig;

The trade-off is real and worth stating plainly: server features — Server Actions among them — aren't available under a static export, since there's no running server process to handle them. The dedicated static-exports article in this series covers the full compatibility boundary in depth.

Migrating an existing SPA incrementally

None of the above requires a rewrite. Dedicated migration guides exist for the two most common starting points — Create React App and Vite — and if you're already on Next.js's own Pages Router, there's a separate, distinct path for incrementally adopting the App Router itself, all covered as their own articles elsewhere in this series.

Key Takeaways

PatternWhat it solves
Automatic code-splitting + next/link prefetchingThe "too much JS upfront" cost of a strict SPA
Server-started promise + use() in a Client ComponentClient-side data waterfalls
next/dynamic with ssr: falseLibraries that assume window/document always exist
window.history.pushState/replaceStateShallow routing carried over from an existing SPA migration
Server Actions + useOptimistic/useActionStateInstant-feeling mutations without a hand-rolled client mutation layer
output: 'export'A fully static, serverless deployment — at the cost of losing server features

Next.js doesn't ask you to give up the SPA feel to get its benefits — it asks you to be deliberate about where each piece of the SPA experience actually comes from: routing and prefetching from the framework itself, data streaming from a server-started promise instead of a client-mount fetch, and mutations from Server Actions paired with React's own concurrent UI primitives rather than a bespoke client store. The result reads and feels like a conventional SPA to the end user, while sidestepping the two costs — bundle size and data waterfalls — that made strict SPAs painful to scale in the first place.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper