The Women in Tech SEO website is built using Gatsby. Gatsby is a React-based static site generator. Content is written in Contentful, we create templates of how they content should be presented and Gatsby takes care of generating HTML pages for it.
All pages on the website are public but to protect new private content, we had to support user login and authentication.
Login requests routing
The first step to allow users to login is to provide them with pages where they can enter their details and authenticate with our authentication server. In our case, we use Firebase as our server and we only support email link login.
With that mind, our login flow is as follows:
- User lands on a page and enters their email
- A login link is sent to their inbox that they open
- User gets redirected to a Women in Tech SEO page to programmatically continue the login process
So we need to have at least two routes: one for email input and one for redirect handling.
Gatsby’s default routes is defined during compilation. It picks all React components exported under src/pages
as well any routes declared by calling the createPage
function.
For example, to get a /user/login
route, your folder structure would be:
src
|-> pages
|-> user
| -> login.tsx
Gatsby also supports client-side routing using Reach Router which suits our needs better.
Since we only need the user
subfolder to have login routes, we can create a catch-all React component that will handle the routing, on the client-side, inside this user
subfolder. The catch-all file is named [...]
.tsx.
src
|-> pages
|-> user
| -> [...].tsx
_Note that your hosting environment will not know about the client-side routes, so you will need to configure it to respond with the Gatsby generated HTML file at /user/[...]/index.html
. _
Inside [...].tsx
we can define the routes that we want
import { Router, RouteComponentProps } from '@reach/router';
const Login: React.FC<RouteComponentProps> = () => {
...
};
const VerifyLogin: React.FC<RouteComponentProps> = () => {
...
};
<Router basepath="/user">
<Login path="/login" />
<VerifyLogin path="/verify" />
</Router>
At this point we are able to run gatsby develop
and see how our Login
and VerifyLogin
look in the browser.
User session handling
Having these login routes is great, but we also need to incldue logic to maintain the login state and query our server to decide on what users can see.
We need a place to store user state which will lock/unlock content in an unknown number of React components and React’s Context
is designed for this.
Defining UserContext
Our Context
will always hold the most recent user state as well as allowing React components to update it. With this in mind, we can represent our UserContext
in an interface:
interface IUserContext {
readonly state: UserState;
readonly dispatch: React.Dispatch<UserAction>;
}
and use createContext
to create it:
const userContext = createContext<IUserContext | null>(null);
where UserState
can be whatever information you need to represent a session. For example it could be:
type UserState = "logged" | "logged-out" | "awaiting-callback";
And React.Dispatch
is a function that accepts a UserAction
and returns void:
type dispatch = (action: UserAction) => void;
Finally, UserAction
is a type representing all the actions that can happen to update the UserState
. For example it could be:
type UserAction =
| {
readonly type: 'user/request_login';
readonly payload: { email: string };
}
| {
readonly type: 'user/logout';
readonly payload: null;
};
Providing UserContext
In order for React components to consume a Context
, a Context.Provider
has to exist and our UserContext relies on a UserState
and a dispatch
function to exist. Every time one of these two values change, the provided context value should change as well.
We can start building a component to provide a context value. It basically wraps its children with UserContext.Provider
element.
const UserProvider: React.FC = ({ children }) => {
return (
<UserContext.Provider value={...}>
{children}
</UserContext.Provider>
);
}
Remeber, our context interface has a user state and a dispatch function, so how do we get these?
React has useState
to manage a component’s state and it could be used if your UserState
is small enough. In our case, we had a complex state structure so we opted for using useReducer
to manage our state.
A reducer is effectivly a function that accepts two arguments:
- The current state
- The action needed to update the state
The output is a new state value and with the useReducer
wrapper around it, any new state values will be propagated to React components that read from the state.
So our UserProvider
now looks similar to this:
const UserProvider: React.FC = ({ children }) => {
const defaultState: UserState = "logged-out";
const [state, dispatch] = useReducer(userReducer, defaultState);
const contextValue = {
state,
dispatch,
};
return (
<UserContext.Provider value={contextValue}>
{children}
</UserContext.Provider>
);
}
Everyime contextValue
changes, due to changes in state
, the provided context value will update and children components will update as well.
Consuming UserContext
In most cases, the value of the context can be retrieved by using the useContext
hook, but our case is different since we want to support async actions which a normal dispatch/reducer combination can’t handle.
Essentially, we want to create Thunks
that will run some async logic then call dispatch for us to update the state instead of just exposing the dispatch function.
Also it’s easier to call context.login("test@example.com")
instead of context.dispatch({type: "user/login", payload: { email: "test@example.com"}})
from different places.
We also don’t want to leak implementation details to consumers. Other React components don’t need to know that we are using a redux-like state management and they certainly don’t need to know the details of how to update the state store.
Our exposed interface should be minimal. It should have the current state and whatever actions we want to expose publicly.
interface UseUserContext {
readonly state: UserState;
requestLogin: (email: string) => Promise<void>;
completeLogin: (link: string) => Promise<void>;
logout: () => Promise<void>;
}
To implement this interface and make it accessible to other React components, we can create a custom hook:
export function userContextActions(userContext: {
readonly state: UserState;
readonly dispatch: Dispatch<UserAction>;
}): UseUserContext {
return {
state: userContext.state,
requestLogin: async (email: string): Promise<boolean> => {
await AuthService.requestLogin(email);
userContext.dispatch({
type: 'user/request_login',
payload: { email: email },
});
},
completeLogin: async (link: string): Promise<boolean> => {
await AuthService.login(link)
// get user data from server and dispatch
},
logout: async () => {
await AuthService.logout();
userContext.dispatch({ type: 'user/logout', payload: null });
},
};
}
export const useUserContext: () => UseUserContext = () => {
const userContext = useContext(UserContext);
if (!userContext) {
throw new Error(
'useUserContext must be used within a UserContextProvider.',
);
}
return userContextActions(userContext);
};
With useUserContext
, we now have a single point where other components can observe the current user state and update it.
Protecting Private Content
Now that we have a route to allows users to login and we are able to update our state when a user logs in, we can start locking private content to be avaiable for logged in users.
We have the choice of using client-side rendering or using static page generation for these protected routes.
Since we still need to fetch content and images from Contentful, we opted to use static page generation. Usually, a statically generated page would look like this:
export const query = ... // GraphQL query to fetch content at build time
// React component to render the fetched content
export const OurPage = () => {
return (<div>...</div>)
}
To make content only be available for logged in user, we can query the user state from our user context when the page is loaded. If a user is logged in, then we display the content, otherwise, the user is redirected to login.
export const OurPage = () => {
const userContext = useUserContext();
useEffect(() => {
if (userContext.state !== "logged-in") {
navigate('/user/login');
}
}, [userContext.state]);
return userContext.state === "logged-in" ? (<div>...</div>) : null;
}
Note that this means Google will always see the login page when crawling private pages, but that’s okay, since this content is private and we don’t intend for it to be indexable.
The final part is to make sure the state is the same between pages. As it stands, our UserContext
stores the user state in memory for each page and since our private content is statically generated, this memory will not be shared between pages.
We will have to modify our context to store the user state whenever it changes. The context provider will also need to consider the stored state when creating the initial state value.
Since UserProvider
already observes the state
that useReducer
updates, we can use the useEffect
hook to store the new state when it changes. For example, in this code snippet, we are storing the updated state in local storage:
const UserProvider: React.FC = ({ children }) => {
const defaultState: UserState = "logged-out";
const [state, dispatch] = useReducer(userReducer, defaultState);
useEffect(() => {
window.localStorage.setItem("user_state", JSON.stringify(state));
}, [state]);
const contextValue = {
state,
dispatch,
};
return (
<UserContext.Provider value={contextValue}>
{children}
</UserContext.Provider>
);
}
We can also rely on useEffect
to execute an action when UserProvider
is mounted. This action can be reading the current user state value from local storage and initialising the state store before mounting the provider’s children.
const UserProvider: React.FC = ({ children }) => {
const defaultState: UserState = "logged-out";
const [state, dispatch] = useReducer(userReducer, defaultState);
useEffect(() => {
window.localStorage.setItem("user_state", JSON.stringify(state));
}, [state]);
const [isInitialised, setIsInitialised] = useState(false);
useEffect(() => {
const storedState = window.localStorage.getItem("user_state");
dispatch({ type: "user/updated", payload: storedState });
setIsInitialised(true);
}, [state]);
const contextValue = {
state,
dispatch,
};
return (
<UserContext.Provider value={contextValue}>
{isInitialised && children}
</UserContext.Provider>
);
}
Now, every time a page is loaded, the state store will be initialised with the cached user state and the user will either be logged in and allowed to see content or they will be redirect to the login page.