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
- Paths
- Image optimization
- Environment variables
- Authentication
- Dynamic Imports
- Router Loading times
- GraphQL Clients
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/*"],
}
}
}
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 :
- Logout :
- 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.
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 });
};
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 });
};
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 fromnext/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;
};
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;
}
}
};