Skip to content

Instantly share code, notes, and snippets.

@gaearon
Last active March 28, 2024 09:26
Show Gist options
  • Save gaearon/9d6b8eddc7f5e647a054d7b333434ef6 to your computer and use it in GitHub Desktop.
Save gaearon/9d6b8eddc7f5e647a054d7b333434ef6 to your computer and use it in GitHub Desktop.
Next.js SPA example with dynamic client-only routing and static hosting

Next.js client-only SPA example

Made this example to show how to use Next.js router for a 100% SPA (no JS server) app.

You use Next.js router like normally, but don't define getStaticProps and such. Instead you do client-only fetching with swr, react-query, or similar methods.

You can generate HTML fallback for the page if there's something meaningful to show before you "know" the params. (Remember, HTML is static, so it can't respond to dynamic query. But it can be different per route.)

Don't like Next? Here's how to do the same in Gatsby.

Building

npm run build

This produces a purely static app in out/ folder.

It's purely static in the sense that it doesn't require Node.js — but router transitions are all client-side. It's an SPA.

Deploying

Host the out/ folder anywhere on a static hosting.

There is however an unintuitive caveat — which is maybe why people don't use Next.js widely this way at the moment.

In traditional SPA setups like CRA and Vite, you have one file like index.html that's served for every route. The downside of this is that (1) it's empty, (2) it contains your entire bundle by default (code splitting and React.lazy doesn't help here a lot because it creates waterfalls — you still have to load the main bundle first before it "decides" to load other scripts).

But if you look at what Next.js generated, it's an HTML file per route:

  • out/index.html
  • out/404.html
  • out/stuff/[id].html

So you'd need to teach your static server to do this rewrite:

  • rewrite / to out/index.html
  • rewrite /stuff/whatever to out/stuff/[id].html

The syntax for these rewrites is different for different static hosts, and I don't think there's an automated solution yet.

I suspect this is why a lot of people don't realize Next.js can produce SPAs. It's just not obvious how to set this up.

Ideally I think Next.js should either pregenerate such rewrite lists for common static hosts (e.g. Nginx, Apache) etc or there should be some common format that can be adopted across providers (e.g. Vercel, Netlify, etc).

(Note that if we didn't do next export, Next.js would still create static output, but it would also generate a Node.js server that serves that static output. This is why by default SSG mode in Next.js emits a Node.js app. But it doesn't mean your app needs Node.js by default. Next.js apps don't need Node.js at all by default, until you start using server-only features. But you do need to rewrite your requests to serve the right HTML file for each route.)

This is a better SPA

It makes sense to have an HTML file per route even if you're doing an SPA. Yes, we need to figure out a good way to set up these rewrite maps, but it's strictly better than serving one index.html page with a giant bundle (or a smaller bundle + a bunch of code that's loaded in a waterfall), which is how SPAs are typically done today.

It's also great to have the ability to do a bunch of stuff at the build time. E.g. I can still do getStaticProps + getStaticPaths in this app to pregenerate a bunch of HTML files with actual content. It's still an SPA and still can be hosted statically! I can also eventually decide to add a server, and I don't need to rewrite anything.

// pages/stuff/[id].js
import { useRouter } from 'next/router';
import Link from 'next/link';
import useSWR from 'swr'
export default function Page() {
const router = useRouter();
const id = router.query.id
if (id == null) {
// Static pre-generated HTML
return <h1>Loading...</h1>
}
return (
<>
<h1>Page for {id}</h1>
<ul>
<li>
<Link href="/stuff/1">go to 1</Link>
</li>
<li>
<Link href="/stuff/2">go to 2</Link>
</li>
</ul>
<hr />
<Content id={id} />
</>
)
}
const fetcher = (...args) => fetch(...args).then((res) => res.json())
function Content({ id }) {
const { data, error } = useSWR('https://jsonplaceholder.typicode.com/todos/' + id, fetcher)
if (error) return <h1>Failed to load</h1>
if (!data) return <h1>Loading...</h1>
return (
<pre>{JSON.stringify(data, null, 2)}</pre>
);
}
// pages/index.js
import Link from 'next/link';
export default function Page() {
return (
<>
<h1>Index page</h1>
<hr />
<ul>
<li>
<Link href="/stuff/1">go to 1</Link>
</li>
<li>
<Link href="/stuff/2">go to 2</Link>
</li>
</ul>
</>
);
}
const nextConfig = {
output: 'export',
}
module.exports = nextConfig
{
"name": "foobar",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build && next export",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "13.2.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"swr": "^2.1.0"
},
"devDependencies": {
"eslint": "8.36.0",
"eslint-config-next": "13.2.4"
}
}
@eric-burel
Copy link

eric-burel commented May 2, 2023

@jiangwalle I think your question apply more specifically to global state, so for instance using React context in a layout falls into this category too. I think you can't have a global client-side state in an exported app because you don't have SPA-like navigation. So yeah any navigation will lose global state in an exported app.
Usually, it's not a big issue, because it's true in "normal" Next.js app or any other kind of SPA: if the user reload the page or access a page via the browser URL, you also lose the global state.

@thekirilltaran
Copy link

@tleo19 Hello, it doesn't work https://github.com/leerob/next-static-export-example
Error occurred prerendering page "/spa-post/[id]". Read more: https://nextjs.org/docs/messages/prerender-error

Build error occurred
Error: Export encountered errors on following paths:
/spa-post/[id]/page: /spa-post/[id]
can you help me with it?

@eric-burel
Copy link

eric-burel commented Oct 9, 2023

For those wondering about truly dynamic pages in the App Router + SPA export, this is not yet implemented and tracked here: vercel/next.js#54393
(original issue and closing comment: vercel/next.js#48022 (comment))

@thekirilltaran it's not your fault, this example is documented as not working: https://github.com/leerob/next-static-export-example/blob/4c8dac076b26289bf9ab48fe9cd4ef35bd7abea9/app/spa-post/%5Bid%5D/page.tsx

App Router doesn't support "SPA" dynamic routes at the time of writing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment