Integrating a Remix app with an External Backend

Integrating a Remix app with an External Backend

Handling Authentication and Route protection in a Remix app with an external backend

INTRODUCTION

So, Remix is making quite the buzz lately in the huge Javascript framework ecosystem and having it among your skill set could be a game changer on how you build your frontend application.
But one important fact to note about Remix is that it is not your typical Javascript frontend framework. As a matter of fact, it is not a frontend framework but a full stack framework. It is the closest thing right now to a seamless integration of a React front end and a Nodejs backend.

Now If you read the remix docs or even searched most remix tutorials almost all the examples do not feature an external backend being called with fetch or Axios on the remix components but rather writing all of the backend code on the remix app itself and this is fine, it's great. That's the philosophy of Remix, to create a full stack framework where the client can have a seamless integration with the server in one codebase.

However, you might have an existing backend built with frameworks like Express,Nestjs, django e.t.c want to still integrate it with Remix and take advantage of its many awesome features. We'll be tackling this scenario in this article and looking at how we would integrate authentication in our remix app with an external backend.

Creating a remix project

npx create-remix remix_auth_app

Select "Just the basics"

Select 'Remix app server"

Either Javascript or typescript is fine depending on your preference but I'll go with typescript

Authentication strategy

So let's say you have a backend with a JWT strategy, on login the endpoint returns some data but most importantly a Jwt Access Token which you can use to access protected API endpoints. Typically in a React app using create-react-app or vite, you would store the access token on local storage after login and use the stored access Token to make subsequent API calls to the backend to access protected API endpoints. But with Remix there are more interesting choices to consider given its Server side rendering feature. In this article, we will store the access token on a session on the Remix server.

Setting up a session store in Remix

App/sessions.ts

import {createCookieSessionStorage } from "@remix-run/node";

ype SessionData = {
  credentials: {
    token: string;
    user: {
      email: string;
      name: string;
    };
  };
};

type SessionFlashData = {
  error: string;
};

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage<SessionData, SessionFlashData>({
    // a Cookie from `createCookie` or the CookieOptions to create one
    cookie: {
      name: "__session",

      httpOnly: true,
      sameSite: "lax",
      maxAge: 60*1000*60*60,
      secrets: [process.env.SESSION_SECRET as string],
      secure: true,
    },
  });

export { getSession, commitSession, destroySession };

Now Remix provides many options to store our session data including in a database, cookies, in memory, file storage, and dynamo Db but we are going to go with the cookie session option. Using the createCookieSessionStorage method we initialize the cookie store and its configuration. The createCookieSessionStorage function returns three values, the getSession function which allows us to access our session store, the commitSession which ensures all actions we perform with our session(set, get) are committed, and destroySession.

Login integration

First, we install Axios and set up an API class

npm install axios

api/index.ts

import axios from "axios";
import { METHOD } from "types/methods";

export type LoginPayload = {
  email: FormDataEntryValue | null;
  password: FormDataEntryValue | null;
};



const Api = class Api {
  baseURL: string;
  token: string | undefined;
  constructor() {
    this.baseURL = process.env.API_BASE_URL as string;
    this.token = "";
  }

  initializeInstance = () => {
    let baseURL = this.baseURL;
    console.log(baseURL);

    const instance = axios.create({
      baseURL,
      withCredentials: false,
    });

    instance.interceptors.request.use(
      (config: any) => {
        return config;
      },
      (error: any) => {
        console.log(error);

        return Promise.reject(error);
      }
    );

    return instance;
  };

  publicRequest = (url: string, method: string, data: any) => {
    const instance = this.initializeInstance();
    return instance({
      url,
      method,
      data,
    });
  };

  loginUser = (payload: LoginPayload) => {
    const url = "/login";
    return this.publicRequest(url, METHOD.POST, payload);
  };
};

export default Api;

Next, we set up our login route, using remix Form actions

routes/_index.tsx

import { V2_MetaFunction, json, redirect } from "@remix-run/node";
import type { ActionArgs } from "@remix-run/node";
import Api from "../../api";
import { Form } from "@remix-run/react";
import { useNavigation } from "@remix-run/react";
import { getSession, commitSession } from "../sessions";
import { Constants } from "utils/constants";

export const meta: V2_MetaFunction = () => {
  return [{ title: "Login" }];
};

export async function action({ request }: ActionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  const body = await request.formData();
  const email = body.get("email");
  const password = body.get("password");
  console.log(email);
  const payload = {
    email,
    password,
  };

  console.log(typeof email);

  const api = new Api();
  try {
    const response = await api.loginUser(payload);
    const sessionPayload = {
      token: response.data.access_token,
      user: {
        email: response.data.user.email,
        name: response.data.user.name,
      },
    };
    console.log(response.data.access_token);
    session.set("credentials", sessionPayload);
    return redirect("/dashboard", {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    });
  } catch (error: any) {
    console.log(error);
    return json(error.response.data);
  }
}

export default function Index() {
  const navigation = useNavigation();

  return (
    <div>
      <h2>Login</h2>
      <Form method="post">
        <input type="text" placeholder="Email" name="email" />
        <input type="password" placeholder="Password" name="password" />
        <button type="submit">
          {navigation.state === "submitting" ? "Loading..." : "Login"}
        </button>
      </Form>
    </div>
  );
}

routes/dashboard.tsx

import React from 'react'

const Dashboard = () => {
  return (
    <div>Super secret protected dashboard</div>
  )
}

export default Dashboard

In the code block above we write out the html/jsx for our login form using the remix Form component with a post action. In our remix action function, we access our session using the getSession function. Extract our email and password values from the formData remix passes through our request object and make an API call to our login endpoint on our external backend. If the credentials are correct and we receive our jwt token, we then use our session's set method to store the token on our session store and redirect to a dashboard route which we would later protect.

We should be able to see our token under the cookies tab in our browser with the name "__session" which is the name that was given to the session in our configuration in the session store.

Protecting Routes

To protect our routes we'll create a function to check if a user's session has the access token we store after login and redirect back to the login page if not.

So in our sessions.ts file, we'll define the function

//previous code above


export async function requireUserSession(request: Request) {
  // get the session
  const cookie = request.headers.get("cookie");
  const session = await getSession(cookie);

  // check if session has the credentials
  if (!session.has("credentials")) {
    console.log(session.get("credentials"));

    // if there is no user session, redirect to login
    throw redirect("/");
  }

  return session;
}

And to protect our dashboard we simply run the function in the loader function of the dashboard route.

import { LoaderArgs } from "@remix-run/node";
import React from "react";
import { requireUserSession } from "~/sessions";

export async function loader({ request }: LoaderArgs) {
  return await requireUserSession(request);

  // continue
}

const Dashboard = () => {
  return <div>Super secret protected dashboard</div>;
};

export default Dashboard;

And that's it! Our dashboard route is protected and will redirect to the login page if the user has not been logged in. Now if you've been paying attention you may ask. Is this really safe? why can't some random user go to the browser and just set a cookie name with "__session" with any random value and access the protected route?

Well, you are welcome to try but it wouldn't work, this is because of the session.has() method is not just checking the existence of the "credentials" session key, it is also validating the session value token named "__session" with the secret passed in our session configuration.

  secrets: [process.env.SESSION_SECRET as string]

so defining a secret earlier was very important and such kinds of important variables should be stored as environmental variables as we did. So without a valid token being set in the cookies of our browser, the .has() method will return false and the .get() method will return "undefined".

Making api calls to our protected backend endpoints

It is important to note that the token stored as a cookie in our browser is not the same as the access token received from our external backend, the access token was stored along with the user's email and password in our session and encrypted to generate the token you see in your browser.
But to make API calls to protected endpoints in our external backend we'll need the access token, this is simple. We have access to all our session data in the remix loader and action functions, all we have to do is retrieve the access token from the session and use it to make API calls in the loader or action function.
To do this, we'll add some more functions to our api/index.ts file

import axios from "axios";
import { METHOD } from "types/methods";
import { getSession } from "~/sessions";

export type LoginPayload = {
  email: FormDataEntryValue | null;
  password: FormDataEntryValue | null;
};

const Api = class Api {
  baseURL: string;
  token: string | undefined;
  constructor() {
    this.baseURL = process.env.API_BASE_URL as string;
    this.token = "";
  }

  initializeInstance = () => {
    let baseURL = this.baseURL;
    console.log(baseURL);

    const instance = axios.create({
      baseURL,
      withCredentials: false,
    });

    instance.interceptors.request.use(
      (config: any) => {
        return config;
      },
      (error: any) => {
        console.log(error);

        return Promise.reject(error);
      }
    );

    return instance;
  };

  publicRequest = (url: string, method: string, data: any) => {
    const instance = this.initializeInstance();
    return instance({
      url,
      method,
      data,
    });
  };

  loginUser = (payload: LoginPayload) => {
    const url = "/login";
    return this.publicRequest(url, "post", payload);
  };
  async setToken(request: Request) {
    const session = await getSession(request.headers.get("Cookie"));
    const token = session.get("credentials")?.token;
    this.token = token;
  }
  authClient = (url: string, method: string, data: any) => {
    const instance = this.initializeInstance();
    instance.interceptors.request.use(
      (config: any) => {
        // const token = getStorage(Constants.AUTH_TOKEN);
        config.headers = {
          Authorization: `Bearer ${this.token}`,
        };

        return config;
      },
      (error: any) => {
        return Promise.reject(error);
      }
    );

    return instance({
      url,
      method,
      data,
    });
  };
  getTasks = () => {
    const url = "/tasks";
    return this.authClient(url, "get", {});
  };
};

export default Api;

The setToken() method is used to retrieve the token from our session and set it on our API instance. We then set up an authClient method which will be used to make API calls to protected endpoints, the headers are set to use the JWT access token gotten from our login endpoint. And we have a simple function getTasks point to get some data from a protected endpoint using our authClient.

So putting all these together in our dashboard route.

routes/dashboard.tsx

import { LoaderArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { requireUserSession } from "~/sessions";
import Api from "../../api";

export async function loader({ request }: LoaderArgs) {
  await requireUserSession(request);
  const api = new Api();
  await api.setToken(request);

  const response = await api.getTasks();
  return response.data;
}

const Dashboard = () => {
  const data = useLoaderData();

  return (
    <div>
      <h2>Super secret protected dashboard</h2>
      <h2>Tasks</h2>
      <div>
        {data.map((task: any, index: string) => {
          return (
            <div key={task.id}>
              <p>
                {index + 1}. {task.title}
              </p>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default Dashboard;

So in our loader function, we initialize our Api, call the setToken method on the request and now our Api instance can now call functions to protected endpoints on our backend. We call the getTasks functions and return the data passed. And using the useLoaderData() hook by remix we can access the data on the client and display it on our html/jsx. This results in:

Implementing Log out functionality

To implement log out we just need to remove the "credentials" stored in our session using the unset() method of our session, the user is then redirected back to our login page. This will be done in our actions function.

So our final dashboard code will be as follows:

routes/dashboard.tsx

import { ActionArgs, LoaderArgs, redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { commitSession, getSession, requireUserSession } from "~/sessions";
import Api from "../../api";

export async function loader({ request }: LoaderArgs) {
  await requireUserSession(request);
  const api = new Api();
  await api.setToken(request);

  const response = await api.getTasks();
  return response.data;
}

export async function action({ request }: ActionArgs) {
  const cookie = request.headers.get("cookie");
  const session = await getSession(cookie);
  session.unset("credentials");

  return redirect("/", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

const Dashboard = () => {
  const data = useLoaderData();

  return (
    <div>
      <h2>Super secret protected dashboard</h2>
      <h2>Tasks</h2>
      <div>
        {data.map((task: any, index: string) => {
          return (
            <div key={task.id}>
              <p>
                {index + 1}. {task.title}
              </p>
            </div>
          );
        })}
      </div>

      <Form method="post">
        <div>
          <button type="submit" value="logout">
            Logout
          </button>
        </div>
      </Form>
    </div>
  );
};

export default Dashboard;

Improving the login route

We can improve the login route by redirecting the user to the dashboard page if the user is logged in, also there is no way of displaying errors to the user if the login endpoint returns an error, for example, if the user's email or password was incorrect.

So the final code for the login page is as follows:

routes/_index.tsx

import { LoaderArgs, V2_MetaFunction, json, redirect } from "@remix-run/node";
import type { ActionArgs } from "@remix-run/node";
import Api from "../../api";
import { Form, useActionData, useLoaderData } from "@remix-run/react";
import { useNavigation } from "@remix-run/react";
import { getSession, commitSession } from "../sessions";
import { Constants } from "utils/constants";
import { useEffect, useState } from "react";

export const meta: V2_MetaFunction = () => {
  return [{ title: "Login" }];
};

export async function loader({ request }: LoaderArgs) {
  // get the session
  const cookie = request.headers.get("cookie");
  const session = await getSession(cookie);

  // if the user is logged in, redirect them to the dashboard
  if (session.has("credentials")) {
    return redirect("/dashboard");
  } else {
    return json({ message: "Please login" });
  }
}

export async function action({ request }: ActionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  const body = await request.formData();
  const email = body.get("email");
  const password = body.get("password");
  console.log(email);
  const payload = {
    email,
    password,
  };

  console.log(typeof email);

  const api = new Api();
  try {
    const response = await api.loginUser(payload);
    const sessionPayload = {
      token: response.data.access_token,
      user: {
        email: response.data.user.email,
        name: response.data.user.name,
      },
    };
    console.log(response.data.access_token);
    session.set("credentials", sessionPayload);
    return redirect("/dashboard", {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    });
  } catch (error: any) {
    console.log(error);
    return json(error, {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    });
  }
}

export default function Index() {
  const navigation = useNavigation();
  const actionData = useActionData();
  const [error, setError] = useState(null);

  useEffect(() => {
    if (actionData.error) {
      setError(actionData.error);
      console.log(actionData);
    }
  }, [actionData]);

  return (
    <div>
      {/*   */}
      <h2>Login</h2>
      <Form method="post" className="flex flex-col">
        <h2>{error}</h2>
        <input type="text" placeholder="Email" name="email" />
        <input type="password" placeholder="Password" name="password" />
        <button type="submit">
          {navigation.state === "submitting" ? "Loading..." : "Login"}
        </button>
      </Form>
    </div>
  );
}

Now this is just a simple implementation to handle errors, you can take it further and use a notification library such as react-toastify to send notifications for the error instead. There are a lot more error handling methods Remix offers such as Errorboundaries but that's outside the scope of this article.