javascriptreactjsauthenticationnext.jsnext.js13

Proper way to store a token retrieved client-side in nextjs 13 "App Router" version?


We have a nextjs 13.5 webapp. Nextjs 13 uses a new app router paradigm where all components, even client components, undergo their first render on the server during a full page load (such as after a refresh). From the docs:

To optimize the initial page load, Next.js will use React's APIs to render a static HTML preview on the server for both Client and Server Components.

In our app, the login flow is to login to an external service that provides a token. Then, that token is used to validate other requests (graphql) made to a different external service.

I can store this token in memory using a react context / provider paradigm.

src/app/login/page.tsx

'use client';

import { useState, useCallback, useContext } from 'react';
import { AuthDispatchContext } from '@/contexts';
import { ActionNames } from '@/reducers';

export default function LoginPage() {
  const loginDispatch = useContext(AuthDispatchContext);

  const handleLogin = useCallback((token: string) => {
    loginDispatch({payload: token, type: ActionNames.LOGIN})
  }, []);


  return (
    <main>
      <button onClick={() => handleLogin('a token we got from external service') > Login</button>
    </main>
  );
}

This context is provided at the app layout level.

src/app/layout.tsx

import React from 'react';
import { ApolloWrapper} from '@/lib/apollo-wrapper';
import AuthWrapper from '@/lib/auth-wrapper';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <AuthWrapper>
          <ApolloWrapper>
            {children}
          </ApolloWrapper>
        </AuthWrapper>
      </body>
    </html>
  );
}

After login, other components (children of app root layout) are able to access the updated token, or, clear token through logout, etc. In particular, ApolloWrapper is a component that creates an Apollo graphql client that requires access to the token, which it acquires through the provided context:

src/lib/apollo-wrapper.tsx

'use client';

import React, { useContext, useEffect } from 'react';
import { AuthContext } from '@/contexts';
import {
  ApolloLink,
  HttpLink,
  SuspenseCache,
} from '@apollo/client';
import {
  ApolloNextAppProvider,
  NextSSRInMemoryCache,
  SSRMultipartLink,
  NextSSRApolloClient as ApolloClient,
} from '@apollo/experimental-nextjs-app-support/ssr';

import { AuthContextType } from '@/types';
import {onError} from 'apollo-link-error';
import { AuthAction, ActionNames } from '@/reducers';

function makeClient(authToken: AuthContextType) {
  return function() {
    const httpLink = new HttpLink({
      uri: GRAPHQL_ENDPOINT,
      headers: {
        Authorization: `Bearer ${authToken}`
      },
    });

    return new ApolloClient({
      cache: new NextSSRInMemoryCache(),
      link:
        typeof window === 'undefined'
          ? ApolloLink.from([
            new SSRMultipartLink({
              stripDefer: true,
            }),
            logoutLink.concat(httpLink),
          ])
          : logoutLink.concat(httpLink),
    });
  };
}

function makeSuspenseCache() {
  return new SuspenseCache();
}

export function ApolloWrapper({ children }: React.PropsWithChildren) {
  const authToken = useContext(AuthContext)

  return (
    <ApolloNextAppProvider
      makeClient={makeClient(authToken)}
      makeSuspenseCache={makeSuspenseCache}
    >
      {children}
    </ApolloNextAppProvider>
  );
}

This is all fine and dandy... until refresh. Upon refresh, obviously contexts and providers are cleared to their default states (null in this case).

On refresh, I need the client to not require login. The mechanism I settled on is localStorage:

src/lib/auth-wrapper.tsx

'use client';

import { reducer, ActionNames } from '@/reducers';
import { useReducer, useEffect } from 'react';
import { AuthContext, AuthDispatchContext } from '@/contexts';

export default function AuthWrapper({ children }: React.PropsWithChildren) {
  const [authToken, dispatch] = useReducer(reducer, null);

  // Check if user is already logged in from a previous reload
  useEffect(() => {
    if (localStorage.getItem('authToken') !== ''){
      dispatch({
          type: ActionNames.LOGIN,
          payload: localStorage.getItem('authToken')
      });
    }
  }, []);

  return (
    <AuthContext.Provider value={authToken}>
      <AuthDispatchContext.Provider value={dispatch}>
        {children}
      </AuthDispatchContext.Provider>
    </AuthContext.Provider>
  );
}

This would work, except, on the actual first render of this component, localStorage isn't available, because the first render occurs server-side. Thus the invocation must be stored in a useEffect. Thus, the entire component tree gets one full render before the token can be grabbed off localStorage and set for all the components to use. And thus, this component renders:

/src/app/achildcomponent/page.tsx

'use client';

import { useContext, useEffect } from 'react';
import {
  getMeQuery,
  getMeResponse,
} from '@/lib/queries';
import { useSuspenseQuery } from '@apollo/experimental-nextjs-app-support/ssr';
import { AuthContext } from '@/contexts';

export default function Landing() {
// The following query attempts to run from the server, and thus fails
  const { data, error } = useSuspenseQuery<getMeResponse>(getMeQuery);
  return (
    <main>
      {error ? (
        <p>Error {error.cause as string}</p>
      ) : !data ? (
        <p>Loading...</p>
      ) : data ? (
        <div>
          <ul>
            <li>{data.me.id}</li>
          </ul>
        </div>
      ) : null
      }
    </main>
  );
}

Which attempts to make a graphql query from a client minus a token.

My first thought: no problem, redirect to login on no auth token. But wait... there is an auth token, I don't want the user to re login, which means, oh no, I have to check if there's no auth token because we're not logged in, or if it's because the app is in its first server side render mode.

Second thought: ok at the very least, simply make the graphql dependent components and login component siblings, and both children of an authcontext, and don't show the graphql components if the authtoken isn't available. I went down this route but because my product requirements are to have the first root page be an authed page, and login a route at /login, with an automatic redirect set up to /login if not logged in, my code started getting sloppy super fast and I got the instinct that I was heading down a bad path.

So now I'm here to ask: what am I missing? This is surely a solved problem, and I'm simply not familiar enough with the new App Router paradigm to understand.

What is the optimal mechanism for storing a token acquired by a client-side component in a nextjs 13 App Router application?


Solution

  • do not get confused by app router. token is used to recognize the client, so you still have a client-server architecture here.

    You can store the token in a cookie and that token will automatically be attached to every HTTP request made to the same domain and path associated with the cookie. You can specify additional attributes like the expiration time, security flags, and whether it should be accessible via JavaScript (HttpOnly flag).

    Now token will be attached to each request. you can write middleware to check if there is a valid token or not to process the request.