Skip to content

Instantly share code, notes, and snippets.

@gaearon
Last active March 28, 2024 09:26
Star You must be signed in to star a gist
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"
}
}
@cyio
Copy link

cyio commented Mar 18, 2023

It's like MPA.

@Jacksonmills
Copy link

Clean

@tleo19
Copy link

tleo19 commented Mar 18, 2023

Still using Next.js

what about ditching next/router for react-router-dom

Keep _app and index

move what you would put in pages to be routes and define them in your index file.

you then use next.config to rewrite /:any* to /

@rajikaimal
Copy link

@tleo19 Just use Vite then I guess 🤔

@tleo19
Copy link

tleo19 commented Mar 18, 2023

@rajikaimal

I still think Next could be a valid option here.
what if you decide or finally have the option to run things server side. The refactor to unlock a lot of the next features would be minor.

Vite is also pretty raw outside of the box.

even with this approach, you can still take advantage of some next features. (Even if i agree and a lot of the really nice features are not available for this case scenario.)

@dtinth
Copy link

dtinth commented Mar 19, 2023

We’ve been using this and it has added a bit of maintenance effort, as @gaearon said it is about these rewrite maps, but not just setting up, but also keeping them up-to-date. We occasionally run into a problem where the page is not working correctly when we forgot to update the web server rewrite config (it was an extra manual process from vanilla Next).

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).

Totally agree! Until then, we are left with two options:

  • (1.) Make sure to always update the static hosting configuration each time we add a new dynamic route. Failure to do this results in new dynamic routes not working correctly until it’s resolved.
  • (2.) Inspect the .next/routes-manifest.json which tells us exactly what rewrites we need to set up, and dynamically generate a hosting config based on it.

For (2.), here’s an example of the routes-manifest: $ jq '{staticRoutes,dynamicRoutes}' .next/routes-manifest.json

{
  "staticRoutes": [
    {
      "page": "/",
      "regex": "^/(?:/)?$",
      "routeKeys": {},
      "namedRegex": "^/(?:/)?$"
    }
  ],
  "dynamicRoutes": [
    {
      "page": "/stuff/[id]",
      "regex": "^/stuff/([^/]+?)(?:/)?$",
      "routeKeys": {
        "id": "id"
      },
      "namedRegex": "^/stuff/(?<id>[^/]+?)(?:/)?$"
    }
  ]
}

Maybe someone can create a tool that converts this into popular configs, like Apache, Nginx, Caddy, Firebase Hosting, etc.

@promer94
Copy link

@JLarky
Copy link

JLarky commented Mar 19, 2023

I'm going to put my own 2 cents here:

If you don't want to mess with all those rewrite rules and try to figure out natorious issues with trying to use Next on platforms other than Vercel, you can just use Astro.

How to get started

npx create-qgp@latest -t astro-vite-react-ts

Building

npm run build

This produces a purely static app in dist/ folder.

Deploying

Most hosting platform would gladly use that right away without any aditional configuration, but you can always look into more integrations if you need it.

If you want to add more routes you have two options, first is just add redirect to index.html (or SPA/index.html). This is example of netlify.toml:

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

Second option is just to create Astro route(s) for it, for example cp src/pages/index.astro src/pages/contacts.astro will generate static dist/contacts/index.html for you during build.

This is better migration story than Next

Instead of trying to fight with existing Next router (while paying heavy bundle price for it) you can bring any router that you want in your src/App.tsx file just like you do in CRA. If you want to add SSG/SSR for one or all routes, just replace "client:only" with "client:load". The path that Dan proposes is better if your goal is to eventually migrate to Next/Vercel. Astro is better if you want to stay flexible and maybe you plan to use other frameworks (Svelte/Vue/Solid) or other meta-frameworks (like if your exsting app uses CRA with react-router and you want to eventually migrate to Remix, but you want to add server side rendering gradually). Here's my example of gradual migration from CRA told with lighthouse scores https://twitter.com/JLarky/status/1633151658074034176 I also migrated my next.js blog to astro with great success https://twitter.com/JLarky/status/1556858475191234560

@bradwestfall
Copy link

This is Gatsby

@ttebify
Copy link

ttebify commented Mar 22, 2023

Thanks for this Dan. One of the main reason why I switched to Gatsby was because I thought this was not possible.

@verekia
Copy link

verekia commented Mar 22, 2023

Is there any way to make a client-only SPA with the app folder? (I'm afraid I already know the answer, but might as well ask).
I really want to use the new Layout system but without needing a server for RSC.

@tleo19
Copy link

tleo19 commented Mar 22, 2023

Is there any way to make a client-only SPA with the app folder? (I'm afraid I already know the answer, but might as well ask). I really want to use the new Layout system but without needing a server for RSC.

@leerob is working on a example:
https://github.com/leerob/next-static-export-example

@eric-burel
Copy link

eric-burel commented Mar 22, 2023

A few notes on this:

  • "but don't define getStaticProps and such" -> it kinda contradicts the end of the README. getStaticProps is still relevant for data fetched at build-time, that is meant to be shared by multiple users. Only getServerSideProps is to be avoided.
  • I would avoid the SPA wording, this is really not an SPA, I see @leerob also uses "spa-post" terminology too, again not an SPA for me. This is just "CSR", client-side rendering/data fetching as opposed to data fetching (btw this raises an interesting point, the distinction between when/where we fetch data and when/where we render, it's often implicit because it doesn't really exists in Next.js, but for instance Gatsby DSR makes the distinction).
  • An SPA would be using only "_app.tsx" in v12 or "app/page.tsx" in v13+ and involving a client-side router, this is also a valid pattern in Next.js. See https://colinhacks.com/essays/building-a-spa-with-nextjs. So what's done here is more a "static app" maybe.
  • nginx can be used for the router, but why not a good old Next.js middleware? Edge runtime is supported by more and more hosts and doesn't involve learning the most unintelligible technology in the world nginx, it's just JS.
  • before this is even used as a counter-argument against this pattern: yes you can have private (paid content, secure content etc.) or personalized (A/B tests, i18n) pages with this. Just handle auth/check/redirection in your proxy server. I call this "segmented rendering". This may require having an edge-friendly database (Next.js middleware supports only HTTP communication not TCP) or being able to verify the JWT without calling a db (as opposed to session-based auth)

I am really glad to see this happening, I've tried to achieve that 3 years ago and was desperate with the lack of examples in the wild, for the router part namely.

(cc @Sangrene I've tried that to allow hosting Aplines app on S3 in the past)

@Wolfr
Copy link

Wolfr commented Mar 23, 2023

I am wondering... why would you go through this and use NextJS in the first place?

@steinitz-detexian
Copy link

I am wondering... why would you go through this and use NextJS in the first place?

This seems a valid, serious question. Are there really no lighter-weight alternatives to, say, cra? Vite comes to mind but I saw the phrase “pretty raw outside the box”, above. What are examples of that? Rawer than cra?

@eric-burel
Copy link

eric-burel commented Mar 23, 2023

My understanding is that CRA will output an SPA in the traditional meaning: an index.html + main.js + main.css combo. This means you need a JavaScript router, as all routes will actually go through the index.html page.

This experiment doesn't need a JavaScript router. The name "SPA" is also used here however to me it's a "static" or "exported" MPA (Multi-Page Application). It creates multiple pages for your application, "index.html", "about.html"... and the hard part is handling dynamic routes that accepts parameters, like "foobar.com/post/1". If you have a fixed number of posts, you can generate "posts/1.html", "posts/2.html", but if you have an infinite number, you should instead generate a generic "post/[postId].html" page and use URL rewrites to point to the right page.

I find this approach pretty-lightweight, what has made it uncommon until now is that 1) without RSC you needed hydration everywhere, making a static website like this potentially less performant than non-React alternatives 2) the URL rewrite thing is annoying to code, and few frontend devs have the required devops skill (and time) to produce an OSS lib to cover this use case.

@Jacksonmills
Copy link

So nextjs in this case would be a client only MPA with a opinionated file based routing. ORA (Opinionated Route App) ? idk

@Jacksonmills
Copy link

After working with nextjs for years now, you just forget routing is even opinionated because it just becomes second nature

@jldec
Copy link

jldec commented Mar 24, 2023

I thought this gist was really helpful - Thanks @gaearon.

I have been looking for a minimal react SPA setup for a new project, and was just starting to evaluate libraries for routing and serving pages, preferably building statically hostable UI files.

If anyone else is looking at this gist, I pushed the files to an easy-to-clone-and-run repo at https://github.com/jldec/gearon-next-spa

@leerob
Copy link

leerob commented Mar 27, 2023

Appreciate the example and all of the discussion here. It was very helpful as I worked on getting a new version of the static export docs for the App Router. I just shipped those: https://twitter.com/leeerob/status/1640445075959484418

I also really like the suggestion of reading the route manifest, similar to https://github.com/geops/next-nginx-routes, as part of Next.js. I think this could be a great improvement in the future. Maybe output: 'nginx'.

@duncand
Copy link

duncand commented Apr 4, 2023

Thank you for all this.

@shenry1-PINC
Copy link

Does anything like the "proxy" configuration from CRA exist for Next.js client only apps?
https://create-react-app.dev/docs/proxying-api-requests-in-development/

I tried using Next.js "rewrites" but they're not compatible with "output: export".
https://nextjs.org/docs/advanced-features/static-html-export#unsupported-features

@tleo19
Copy link

tleo19 commented Apr 4, 2023

Does anything like the "proxy" configuration from CRA exist for Next.js client only apps? https://create-react-app.dev/docs/proxying-api-requests-in-development/

I tried using Next.js "rewrites" but they're not compatible with "output: export". https://nextjs.org/docs/advanced-features/static-html-export#unsupported-features

I guess it depends on the scenario and what you need,
but I have been using fetch()

@shenry1-PINC
Copy link

Does anything like the "proxy" configuration from CRA exist for Next.js client only apps? https://create-react-app.dev/docs/proxying-api-requests-in-development/
I tried using Next.js "rewrites" but they're not compatible with "output: export". https://nextjs.org/docs/advanced-features/static-html-export#unsupported-features

I guess it depends on the scenario and what you need, but I have been using fetch()

During development, I want to proxy requests like fetch('/api/todos') to another port than the one my next.js bundle is being served from, with minimal configuration.

@jiangwalle
Copy link

My understanding is that CRA will output an SPA in the traditional meaning: an index.html + main.js + main.css combo. This means you need a JavaScript router, as all routes will actually go through the index.html page.

This experiment doesn't need a JavaScript router. The name "SPA" is also used here however to me it's a "static" or "exported" MPA (Multi-Page Application). It creates multiple pages for your application, "index.html", "about.html"... and the hard part is handling dynamic routes that accepts parameters, like "foobar.com/post/1". If you have a fixed number of posts, you can generate "posts/1.html", "posts/2.html", but if you have an infinite number, you should instead generate a generic "post/[postId].html" page and use URL rewrites to point to the right page.

I find this approach pretty-lightweight, what has made it uncommon until now is that 1) without RSC you needed hydration everywhere, making a static website like this potentially less performant than non-React alternatives 2) the URL rewrite thing is annoying to code, and few frontend devs have the required devops skill (and time) to produce an OSS lib to cover this use case.

I was wondering if we use state management tool like redux, react-query etc (pretty natural for SPA). Will it still work for this MPA? Or in other words, will states stay after moving to a new route?

@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