πŸ”–

Style Guides, Practices & Methodologies

This will include practices with other popular packages as well. Since NextJS really isn't your out-of-the-box React it will take some maneuvering to set up some things which need to work smoothly with SSR pages.

Disclaimer : Please come here after going through the docs at least briefly.

TOC

Project Structure Example

  root
  β”œβ”€β”€ components
  β”‚   β”œβ”€β”€ adaptors # These primary UI adaptors, eg: buttons, checkboxes, inputs.
  |   β”‚   β”œβ”€β”€ CustomButton
  |   |   |   β”œβ”€β”€ index.js
  |   |   |   β”œβ”€β”€ CustomButton.module.scss
  |   β”‚   β”œβ”€β”€ ...other adaptor components
  β”‚   β”œβ”€β”€ ...broader components
  β”œβ”€β”€ pages
  |    β”œβ”€β”€ _app.js
  |    β”œβ”€β”€ ...pages
  β”œβ”€β”€ styles
  |   β”œβ”€β”€ ...page styles, helper styles, globals
  |── layouts
  |   β”œβ”€β”€ RootLayout, ....

Paths

Create a jsconfig.js file for this. Have aliases for the paths so nesting imports don't occur.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@components/*": ["components/*"],
    }
  }
}
jsconfig.js

Your imports will be much cleaner and it's easy to move files or change names as everything is absolute.

// Bad
import SomeComponent from "../../../SomeComponent";

// Good
import SomeComponent from "@components/SomeComponent";

Image optimization

If you are under the assumption that Next Images cannot optimize images dynamically, you're mistaken, we can optimize images coming from FE S3 or even S3 links which are attached from an API.

We will have to do the following inside next.config.js β†’

images: {
      domains: [
        process.env.NEXT_PUBLIC_IMAGE_CDN_DOMAIN,
        process.env.NEXT_PUBLIC_SVG_CDN_DOMAIN,
        process.env.NEXT_PUBLIC_API_CDN_DOMAIN,
      ],
      deviceSizes: [350, 450, 500, 640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    },

You can read more about it here.

Environment variables

This is going to be a mixed bag. You can handle this in two ways :

  • In next.config.js itself -
// Bad
Example

We can see here there's just a lot of unnecessary things going on along with the fact β†’ Even sensitive keys are exposed and in the codebase itself.

  • Look into creating the normal .env.local file and having your keys there. There are some guidelines which you can check here.
    • If you are having CI/CD processes, try to come up with an encryption flow for the env keys. We have this documented here for Unschool.

Authentication

There would be some situation for authenticated pages or in general a lot of logic required from an SSR function on the server. To keep things organized always return the response from a separate file like ssr.js

export async function getServerSideProps(ctx) {
  const { req } = ctx;
  if (req?.cookies[AUTH_TOKEN]) {
    let response = await secureAuthenticatedEndpoints({
      ctx,
      origin: COURSE_ROUTE,
    });
    if (!response?.authenticated) {
      return response.body;
    }
    if (response?.authenticated) {
      return response.ssrResponse;
    }
  } else {
    return {
      redirect: rootRedirect,
    };
  }
}
  • For cookies, we handle the logic on server-side as well. One more thing you can leverage is β†’ If you're using SSR, keep in mind you can opt-in for server side cookies!
    • This is much more secure as it's HTTP only and avoids XSS attacks.
    • We can easily do this by writing some Node.js code inside our /api folder.
    • Login :
    • import cookie from "cookie";
      import { ACCESS_DENIED_ENDPOINT, AUTH_TOKEN, SET_COOKIE } from "@localization";
      import { cookieSerializeOptions } from "@constants";
      
      export default (req, res) => {
        const { token, key } = req.body;
        if (!key || key !== process.env.NEXT_PUBLIC_SERVER_ACCESS_KEY) {
          return res.redirect(ACCESS_DENIED_ENDPOINT);
        }
        if (!token) {
          return res.status(401).json({
            message: "Invalid Token",
          });
        }
        res.setHeader(
          SET_COOKIE,
          cookie.serialize(AUTH_TOKEN, token, {
            ...cookieSerializeOptions,
            maxAge: 60 * 60 * 24 * 30,
          })
        );
        return res.status(200).json({ success: true });
      };
      login.js
    • Logout :
    • import cookie from "cookie";
      import { ACCESS_DENIED_ENDPOINT, AUTH_TOKEN, SET_COOKIE } from "@localization";
      import { cookieSerializeOptions } from "@constants";
      
      export default (req, res) => {
        const { key } = req.body;
        if (!key || key !== process.env.NEXT_PUBLIC_SERVER_ACCESS_KEY) {
          return res.redirect(ACCESS_DENIED_ENDPOINT);
        }
        res.setHeader(
          SET_COOKIE,
          cookie.serialize(AUTH_TOKEN, "", {
            ...cookieSerializeOptions,
            maxAge: new Date(0),
          })
        );
        return res.status(200).json({ success: true });
      };
      logout.js
  • Keep in mind to secure the /api pages as anyone can access them. You can have a hex key here to protect it and redirect it to a 403 if the key isn't passed while making the request.
  • From the FE you can just hit the end-point β†’ /api/login using fetch, if you want to use the login and logout actions on the server itself, then use a package called isomorphic-unfetch.

Dynamic Imports

There will be situations when using some packages or even Font Awesome Kits, where there will be a mismatch of rendered HTML on the client-side vs server-side.

Example :

Expected server HTML to contain a matching <svg> in <nav>

This is mostly because the package is not designed or coded to render the right HTML code on the server-side but it renders correctly on the client-side.

For these situations + for a situation where you know you can import components / packages only when needed β†’ You can use Dynamic imports.

  • For the server-side mismatch errors, please include ssr: false while dynamically importing components.
const DynamicComponentWithNoSSR = dynamic(
  () => import('../components/hello3'),
  { ssr: false }
)
// Will render properly on client-side only

Router Loading times

We will have to keep in mind if you're using SSR with all page queries on the server-side, yes there will be no loading screens needed, but let's say you want to go from one authenticated page to another, where both have page queries and some logics β†’ This will take some time on the first load.

  • From the second load, depending on which GraphQL client you are using β†’ Say for example Apollo GraphQL which supports server-side GraphQL queries, these will get cached.
  • On redirecting or going to another route, Next JS will handle this by staying on the current page whilst it makes the queries and loads the new page, then redirects, very similar to blocking mode present in other data fetching methods.
  • It is around 1-1.5 seconds. For this, we would need to handle it properly as it can be a bad UX.
    • Handle this by listening to Router events on the _app.js file. By Router, I mean from next/router
    • Have a progress bar on the top of the page. You can use a good library called nprogress for this.

GraphQL Clients

As mentioned in the Starter Guide, this can be of two ways. We are going to see the Apollo client as it is more complex with SSR queries.

  • Caching, the main thing we want here is server-side queries and client-side queries to be cached.
  • Before looking into the code below, understand the below code solves the issue of β†’ Setting Authentication headers when executing queries from the server-side.
    • We have to keep in mind the server doesn't have access to local storage or redux or any of these things.
import {
  ApolloClient,
  createHttpLink,
  from,
  InMemoryCache,
} from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { getPersistedAuthToken } from "@persistance";
import { isSSR } from "@helpers/global";
import { AUTH_TOKEN } from "@localization";
import merge from "deepmerge";
import { isEqual } from "lodash";
import { useMemo } from "react";

let apolloClient;

const httpLink = createHttpLink({
  uri: process.env.NEXT_PUBLIC_API_ENDPOINT,
});

const getAuthLink = (ctx) => {
  return setContext((_, { headers }) => {
    return {
      headers: {
        ...headers,
        authorization:
          Boolean(ctx) || isSSR()
            ? ctx?.req?.cookies[AUTH_TOKEN]
            : getPersistedAuthToken(),
      },
    };
  });
};

const createApolloClient = (ctx) => {
  return new ApolloClient({
    ssrMode: Boolean(ctx) || isSSR(),
    link: from([getAuthLink(ctx), httpLink]),
    cache: new InMemoryCache(),
  });
};

export const initializeApollo = ({ initialState = null, ctx = null }) => {
  const _apolloClient = apolloClient ?? createApolloClient(ctx);
  if (initialState) {
    const existingCache = _apolloClient.extract();
    // Merge the existing cache into data passed from getStaticProps/getServerSideProps
    const mergedCache = merge(initialState, existingCache, {
      // combine arrays using object equality (like in sets)
      arrayMerge: (destinationArray, sourceArray) => [
        ...sourceArray,
        ...destinationArray.filter((d) =>
          sourceArray.every((s) => !isEqual(d, s))
        ),
      ],
    });
    _apolloClient.cache.restore(mergedCache);
  }
  if (isSSR() || Boolean(ctx)) return _apolloClient;
  if (!apolloClient) apolloClient = _apolloClient;
  return _apolloClient;
};

/* NOTE: We want the Apollo client instance to be updated only when the cache value has changed, 
let’s use a useMemo hook to achieve that. The useApollo function is defined which calls a useMemo 
hook which returns the memoized value of the Apollo client returned by the call to initializeApollo
and it is recomputed only when the initialState value changes. This returns the Apollo client instance. */

export const useApollo = ({ initialState }) => {
  const store = useMemo(
    () => initializeApollo({ initialState }),
    [initialState]
  );
  return store;
};
apollo-client.js

An example query would be β†’

  • This function gets used in getServerSideProps, please check Authentication.
export const secureAuthEndpoint = async ({ redirect, ctx }) => {
  const { req } = ctx;
  const apolloClient = initializeApollo({ initialState: null, ctx });
  try {
    const { data } = await apolloClient.query({
      query: GET_AUTH_USER_DETAILS,
    });
    if (data?.currentUser) {
      const { firstName, lastName, mobileNumber } = data.currentUser;
      if (!firstName || !lastName || !mobileNumber) {
        return {
          props: {
            nextProps: {
              authState: ADD_USER_DETAILS,
              userDetails: {
                email: data.currentUser?.email ?? "",
                firstName,
                lastName,
                mobileNumber,
                authToken: req?.cookies[AUTH_TOKEN],
              },
            },
            initialApolloState: apolloClient.cache.extract(),
          },
        };
      }
      if (firstName && lastName && mobileNumber) {
        return {
          redirect,
        };
      }
    }
  } catch (error) {
    const gqlError = error.graphQLErrors[0];
    if (gqlError) {
      return;
    }
  }
};