Running Remix with Apollo

Cover Image for Running Remix with Apollo
Peter Nycander
Peter Nycander

Ryan Florence, one of the founders behind the new web framework Remix, tweeted out:

Anybody want to build a @remix_run example with @apollographql?

  • Loaders just use the schema link instead of making a network call to a graphql api
  • Make a Resource Route to expose the graphql schema to other apps

https://twitter.com/ryanflorence/status/1471935423249219586?s=20

Since I have been following the Remix journey, but being quite sceptical of it I felt that this was a good excuse to try it out. And beind very experienced with Apollo GraphQL, I thought I could have something to contribute. And no, I have not followed any of the tutorials as they suggest šŸ™ˆ, but I hope this can still be a valuable asset.

You can find the entire example at: https://github.com/peternycander/apollo-remix-example

Setup of Remix

If you find this post in 2022 or later, it is likely that these steps are outdated. Please visit the Remix docs for instructions. This post is mainly documentation of my experience setting up Remix and Apollo.

First, I set up a new project using the Remix cli:

$ npx create-remix@latest

Which gave me the following output:

Need to install the following packages:
  create-remix@latest
Ok to proceed? (y) y

R E M I X

šŸ’æ Welcome to Remix! Let's get you set up with a new project.

? Where would you like to create your app? apollo-remix-example
? Where do you want to deploy? Choose Remix if you're unsure, it's easy to change deployment targets. Remix App Server
? TypeScript or JavaScript? TypeScript
? Do you want me to run `npm install`? Yes

I noticed that Remix did not initialize a git repository for me, so I'll start with that:

git init
git add -A
git commit -m "Project created by Remix CLI"

Looking at the package.json scripts: I see that we have "dev", "start" and "build", I assume "start" and "build" is for production, so I'll go ahead and run npm run dev.

Remix starts up, and I can reach localhost:3000 in my browser, cool.

I notice that there are no loaders in the initial setup, and from my shallow understanding of Remix I thought those were the basis of data fetching. Hmmm, I guess they want to imply that you don't need that for all sites.

Well, no matter, a quick look into the docs and I've learned that route modules can export const loader and it should just work after that. I replaced the code in app/routes/index.tsx with this:

import { LoaderFunction, useLoaderData } from "remix";

export const loader: LoaderFunction = () => {
  return {
    data: "I will be replaced with data from GraphQL!",
  };
};

export default function Index() {
  const loaderData = useLoaderData();

  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
      {loaderData.data}
    </div>
  );
}

I noticed how Typescript did not give me any types for the return data, but that is maybe fixable with local types...

Adding Apollo

The suggested approach was to use apollo-link-schema, which is documented as a thing for SSR and/or mocking data. Seems a bit weird to use as the basis of a server, but Remix uses the loaders for both SSR and fetch calls as far as I know, so maybe it makes sense?

I install the needed libraries:

npm install @apollo/client @graphql-tools/schema

I added a GraphQL schema to the code base at app/schema.ts, tried wiring it up to the loader, but I got the error Warning: Did not expect server HTML to contain a <script> in <html>.. Hmm, weird. I must be doing something wrong. The error happens during hydration, but the produced SSR HTML does not include the component data, interesting.. Checking the Remix CLI output, nope nothing. Googled the error, found nothing. I guess this is an issue for discord, but I'm a stubborn guy and I hate feeling like I'm bothering someone.

So I made my example simpler and simpler, but even when I was back to the last working state, the error persisted. But now I get html output. I guess the error message was a red herring, but Remix is still new so I guess it is to be expected.

And bringing everything back again, now it is working. Black. Magic. I guess maybe some cache issue or something?

Anyway, at this state the index route module looks like this:

import { LoaderFunction, useLoaderData } from "remix";
import { ApolloClient, gql, InMemoryCache } from "@apollo/client";
import { SchemaLink } from "@apollo/client/link/schema";

import { schema } from "../schema";

const graphqlClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: new SchemaLink({ schema }),
});

const IndexQuery = gql`
  query IndexQuery {
    currentUser {
      id
      name
    }
  }
`;

export const loader: LoaderFunction = async () => {
  const { data, error } = await graphqlClient.query({ query: IndexQuery });
  if (error) {
    throw error;
  }

  return { data };
};

export default function Index() {
  const loaderData = useLoaderData();

  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
      {JSON.stringify(loaderData.data)}
    </div>
  );
}

And app/schema.ts:

import { gql } from "@apollo/client";
import { makeExecutableSchema } from "@graphql-tools/schema";

const typeDefs = gql`
  type User {
    id: ID!
    name: String
  }

  type Query {
    currentUser: User
  }
`;

const mockUser = {
  id: 1,
  name: null,
};

export const schema = makeExecutableSchema({
  typeDefs,
  resolvers: {
    Query: {
      currentUser: () => mockUser,
    },
  },
});

Great, but now I want to look at mutations.

Adding mutation

I think you are supposed to do all mutations in Remix through forms, in part to work without Javascript. I'll give that a try.

Setting up the <Form /> and action with dummy data was easy enough, but using the FormData object from request.formData() was awkward. All I want is a JSON with the form data, it seems like Remix should have some convenience method for that.

Other than that, it was smooth sailing.

Index module route is now:

import {
  ActionFunction,
  Form,
  LoaderFunction,
  redirect,
  useActionData,
  useLoaderData,
  useTransition,
} from "remix";
import { ApolloClient, gql, InMemoryCache } from "@apollo/client";
import { SchemaLink } from "@apollo/client/link/schema";

import { schema } from "../schema";

const graphqlClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: new SchemaLink({ schema }),
});

const IndexQuery = gql`
  query IndexQuery {
    currentUser {
      id
      name
    }
  }
`;

const UpdateNameMutation = gql`
  mutation UpdateNameMutation($name: String!) {
    currentUserUpdate(name: $name) {
      updatedUser {
        id
        name
      }
      error
    }
  }
`;

export const loader: LoaderFunction = async () => {
  const { data, error } = await graphqlClient.query({ query: IndexQuery });
  if (error) {
    throw error;
  }

  return { data };
};

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();

  const inputData = {} as any;
  formData.forEach((value, key) => (inputData[key] = value));

  const { data } = await graphqlClient.mutate({
    mutation: UpdateNameMutation,
    variables: inputData,
  });

  return { data };
};

export default function Index() {
  const loaderData = useLoaderData();
  const actionData = useActionData();
  const transition = useTransition();

  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
      <div>Query: {JSON.stringify(loaderData.data)}</div>
      <div>Mutation: {actionData && JSON.stringify(actionData.data)}</div>
      <Form method="post">
        <fieldset disabled={transition.state === "submitting"}>
          <label>
            Name
            <input
              name="name"
              type="text"
              defaultValue={loaderData.data.currentUser.name}
            />
          </label>
          <button>Save</button>
        </fieldset>
      </Form>
      {transition.state}
    </div>
  );
}

and app/schema.ts:

import { gql } from "@apollo/client";
import { makeExecutableSchema } from "@graphql-tools/schema";

const typeDefs = gql`
  type User {
    id: ID!
    name: String
  }

  type Query {
    currentUser: User
  }

  type Mutation {
    currentUserUpdate(name: String): CurrentUserUpdateResponse!
  }

  type CurrentUserUpdateResponse {
    updatedUser: User
  }
`;

const mockUser = {
  id: 1,
  name: null,
};

export const schema = makeExecutableSchema({
  typeDefs,
  resolvers: {
    Query: {
      currentUser: () => mockUser,
    },
    Mutation: {
      currentUserUpdate: (_, { name }) => {
        mockUser.name = name;
        return {
          updatedUser: mockUser,
        };
      },
    },
  },
});

Updating the name works, but the Apollo cache magic is gone! In a normal client-side app with apollo-client, the query data will be updated whenever the mutation reponse returns. This is not surprising, since all the GraphQL stuff is done on the backend so far. To get this working on the client, my assumption is that we need to:

  • Hook up a real apollo client on the frontend
  • Hydrate the apollo client cache during SSR
  • Probably not use Remix actions, but instead use a GraphQL endpoint... Speaking of which, I'll try adding one!

GraphQL Resource route

I started by going to the Apollo docs. We have a schema, but we want the schema to be able to handle normal HTTP requests as well, not just from Remix loaders. The Apollo docs points to the different implementations for different web servers like express. Since I used the Remix built-in server which is missing from the Apollo list, I guess I need to change my Remix setup to something like express.

I really struggled to find how to change web server. I tried looking at the docs and googled. Oh well, when all else fails: I created a new project with express as the choice to see what is different. I noticed that the dependencies differed, and that we now have a server/index.js. We also have a small difference in remix.config.js. I copied those over from the new project and ran another npm install.

It is not working anymore! Oh, I need to have another tab running with npm run start:dev, that is kind of awkward, but ok. Okay, now it is working again!

Now that we have express, surely I can connect Apollo and express inside remix together. However, Ryan wanted this to be a resource route, and inside a resource route I don't know how we can connect the Apollo server to the express instance like the docs say, hmmm. I will just give up for now and try to connect it in server/index.js.

First thought: why is not Typescript in here? Is that going to be a problem when importing my schema? A few minutes later... Yep, could not import Typescript to this file... That sucks. Okay, I will change my schema to Javascript then. Oh what, I can't use JS modules? I guess I will convert to require then...

Okay, yay it is working. That experience was not great though. Maybe next time I'll try to write a custom apollo server integration to work straight up with resource routes, but that will be for another time.