LaunchedMotionShot- Make informative screen guides for your products, tools, and SOPs!
Published on

How to build SPA with NextJS

Authors

At this point, I consider myself a to-be serial SaaS builder. Sounds crazy! Isn’t it? Anyways, for my previous product, Tweelog. I regret that I did not start it keeping SEO in mind. I slowly understood how important it is to have that organic traffic channel. Some people even say SEO is the best marketing strategy. It is absolutely true in my opinion. For people like me, solopreneurs, it is even more important because we cannot spend so much money on marketing, not enough manpower.

So, I decided my next product be SEO first. Currently, I am building a personal wealth tracking tool. More about this product later, I started building this product on NextJS. My main focus was SEO behind using it.

I feel NextJS, when server-side rendering, is very similar to PHP. All the magic happens in the backend and the frontend just gets the final HTML, JS, and CSS to rendering. Another thing I liked NextJS for is, the ease to deploy it on Vercel. Within 15 seconds from the time you push any change, it would be deployed on Vercel. It is an amazing experience.

Problem

In my app, there is homepage where I explain my product, features etc. and then there is a dashboard where a logged-in user comes and manages his financials like accounts, transactions, balances etc.

NextJS comes with its own router and routing mechanism. There is pages/ a folder and keep any nested Javascript files there and they become accessible URLs. For example, pages/about.js will be accessible at http://…/about.

My requirement for this internal dashboard is

  1. I don’t need SEO for this dashboard.
  2. It should give that single-page application experience. Everything is super fast

When I say SPA, I mean the react-router-dom kind of routing also. If I have a page/app.jshttps://…/app, from that point, there should be a SPA running on the browser. I quickly thought, “Oh, let's just use react-router-dome inside app.js” only then I figure out, that it is not that trivial because of NexJS hydration process.

NextJS hydration error

In a nutshell, hydration process is that it renders the app both in backend and frontend and compares them to see if they are the same. If not, it is a red flag. Ideally what is rendered at server-side and first render on browser should match. Else there will be inconsistencies.

Here is an interesting exploration — https://colinhacks.com/essays/building-a-spa-with-nextjs by Colin McDonnell

I have tried what is told in the above blog post. I was getting some weird error maybe because of new versions of react-router-dom. I thought I will try to solve it from the first principles.

Solution

The following is what I want

  1. A NextJS page to run SPA
  2. A mini router system that does not create any problem with NextJS hydration process

To solve the first problem, I can use NextJS dynamic routing. pages/app/[[…path]].js would actually catch all the URLs which start with app/ and renders [[…path]].js file. This is a good starting point. Inside [[..path]].js I can have a basic check window.location.pathname to render the corresponding component as shown below

NextJS pathname check

The reason why it does not create any issue in hydration process is that we are setting path state in useEffect. NextJS run useEffect only on client side. So, the server side rendered version and the first render on client side will be same. All happies!

The next problem is, this basic check on pathname is not sufficient. I need a full fledged routing system. That does

  1. Updates the URLs in the URL bar
  2. Browser back button should work
  3. Not so important: Nested routing

So I started building my own router. I know this is unnecessary but I did not find any other working way for me :(

import React, { Children, ComponentProps, MouseEventHandler, PropsWithChildren, useContext, useEffect } from "react"
import { useState } from "react"

interface Context {
  route: string
  setRoute: (route: string, replace?: boolean) => void
}

export const ClientRouterContext = React.createContext<Context>({
  route: "/",
  setRoute: (route: string, replace?: boolean) => {}
})

interface RouteProps {
  path: string
  component: JSX.Element
}

export const ClientRoute = ({component, path}: RouteProps) => {
  const {route} = useContext(ClientRouterContext)

  return route === `/app/${path}` ? component : null
}

interface LinkProps extends ComponentProps<"a"> {
  to: string
  replace?: boolean
}

export const ClientLink = ({children, to, replace, ...restProps}: LinkProps) => {
  const handleClick: MouseEventHandler<HTMLAnchorElement> = (e) => {
    e.preventDefault()
    window.history.pushState({}, "", to)
    window.dispatchEvent(new PopStateEvent("popstate"))
  }

  return <a 
    href={to} 
    onClick={handleClick} 
    {...restProps}>
      {children}
    </a>
}

interface RouterProps {
  whileLoading?: JSX.Element
}

export const ClientRouter = ({children, whileLoading}: PropsWithChildren<RouterProps>) => {
  const {setRoute} = useContext(ClientRouterContext)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    syncWithUrl()
    window.onhashchange = (e) => {
      syncWithUrl()
    }
    window.addEventListener("popstate", handlePopState)
    setLoading(false)

    return () => window.removeEventListener("popstate", handlePopState)
  }, [])

  const handlePopState = () => {
    syncWithUrl()
  }

  const syncWithUrl = () => {
    setRoute(window.location.pathname)
  }

  return <>{loading ? whileLoading : children}</>
}

export const ClientRouterProvider = ({children}: PropsWithChildren<{}>) => {
  const [route, setRoute] = useState<string>("/")

  return (
    <ClientRouterContext.Provider value={{
      route, 
      setRoute
    }}>
      {children}
    </ClientRouterContext.Provider>
  )
}
  
// usage
const Dashboard = () => {
  const {user} = useAuth()
  const {summary} = useSummary({user})

  return (
    <ClientRouterProvider>
      <AppLayout summary={summary}>
        <ClientRouter whileLoading={<Loading/>}>
          <ClientRoute path="summary" component={<Summary summary={summary}/>}/>
          <ClientRoute path="transactions" component={<Transactions summary={summary}/>}/>
        </ClientRouter>
      </AppLayout>
    </ClientRouterProvider>
  )
}

As you see above, I was able to use it the way we use react-router-dom. It improved my apps performance drastically. I was able to load the skeleton upfront, show loading and then everything loads up.

NextJS result

This post is just to explain the problem I had, the process I tried to solve but not to suggest to use the same methodology. I am open for better solutions :) You can follow me or reach me on twitter — (@pramodk73)[https://twitter.com/pramodk73]

Update on 24th May 2022 — NextJS has introduced layouts — https://nextjs.org/blog/layouts-rfc This will do all the things I explained above. So, it does not make sense to implement the Router our-self :) but this post explains the pain point and a quick solution for it!