🏙️

Remix(+Graphql)をSSTでデプロイ

に公開

試したかったこと

  • RemixをSSTでデプロイできるか?
  • RemixのSSR部分をGraphqlサーバーにできないか?

結論

  • RemixをSSTでデプロイできるか? → できる
  • RemixのSSR部分をGraphqlサーバーとしても利用できないか? → できる

成果物↓

リポジトリ
https://github.com/webshoten/sst-remix-graphql

技術スタック

  • SST(serverless stack):pulumiをベースとしたIaC。今回はAWSにデプロイ。
  • Remix:Reactをベースとしたフルスタックフレームワーク。
  • pothos:コードファーストGraphqlサーバー。
  • urql:GraphQLクライアント

前提条件

% asdf list
bun
 *1.2.2
nodejs
 *20.9.0

% aws --version
aws-cli/2.24.1 Python/3.12.9 Darwin/24.4.0 source/x86_64

手順

まずはSSTのベースを構築

こちらからSSTのモノレポの雛形を落としてきます。

SSTの公式にあるように、自分のプロジェクト名に変えます(MY_APPのところ)。

npx replace-in-file /monorepo-template/g MY_APP **/*.* --verbose

パッケージをインストールします

npm install

ローカルにAWS認証情報がある状態であれば、この状態でSSTのデプロイが可能です。

npx sst dev

こちらはLambdaでHelloWorldを返すだけのサンプルとなっています。

Remixを初期化

packages配下のcoreとfunctionsは不要なので削除します。

cd packages
rm -rf core
rm -rf functions

packages配下のwebにremixをインストールします。

# packages配下にwebフォルダ作成
mkdir web
cd web
# ※注意。モノレポなのでGithubリポジトリは「作らない」を選んでください。
npx create-remix@latest .

GraphQL関連パッケージ

必要なパッケージインストール

# Graphqlサーバー、クライアントインストール
npm i graphql-yoga @graphql-yoga/common @pothos/core
npm i urql

GraphQLスキーマ作成

Schema作成
従業員データのQueryと従業員追加Mutationを作っておきます。

// packages/web/app/graphql/schema.ts
import SchemaBuilder from "@pothos/core";

export interface Employee {
    id: number;
    name: string;
}

const builder = new SchemaBuilder<{
    Objects: {
        Employee: Employee;
    };
}>({});

builder.objectType("Employee", {
    fields: (t) => ({
        id: t.exposeInt("id"),
        name: t.exposeString("name"),
    }),
});

const employees: Employee[] = [
    { id: 1, name: "hiroshi nohara" },
];

builder.queryType({
    fields: (t) => ({
        employees: t.field({
            type: ["Employee"],
            resolve: () => employees,
        }),
        employee: t.field({
            type: "Employee",
            nullable: true,
            args: {
                id: t.arg.int({ required: true }),
            },
            resolve: (_, args) => {
                return employees.find((employee) => employee.id === args.id) ||
                    null;
            },
        }),
    }),
});

builder.mutationType({
    fields: (t) => ({
        addEmployee: t.field({
            type: "Employee",
            args: {
                name: t.arg.string({ required: true }),
            },
            resolve: (_, args) => {
                const _employee = {
                    // カウントアップ
                    id: (employees && employees.length > 0
                        ? employees?.at(-1)?.id as number
                        : 0) + 1,
                    name: args.name,
                };
                employees.push(_employee);
                return _employee;
            },
        }),
    }),
});

export const schema = builder.toSchema();

Remixで/graphqlアクセスができるように設定

loaderがNextjsでいうAPIRouteのGET、
actionがPOSTの役割を果たします。

// packages/web/app/routes/graphql.ts

import type { LoaderFunction } from "@remix-run/node";
import { createYoga } from "graphql-yoga";
import { schema } from "../graphql/schema";

const yoga = createYoga<{
	req: Request;
}>({
	graphqlEndpoint: "/graphql",
	schema,
	fetchAPI: globalThis,
});

export const loader: LoaderFunction = async ({ request }) => {
	return yoga.handleRequest(request, { req: request });
};

export const action: LoaderFunction = async ({ request }) => {
	return yoga.handleRequest(request, { req: request });
};

Graphqlをクライアント側から呼び出せるように

urqlでGraphqlリクエストができるようにコンテキストを作ります。

// packages/web/app/context/urql-provider.tsx

"use client";

import { cacheExchange, createClient, fetchExchange } from "@urql/core";
import type { ReactNode } from "react";
import { Provider } from "urql";

export default function UrqlProvider({ children }: { children: ReactNode }) {
  const client = createClient({
    url: "/graphql",
    exchanges: [cacheExchange, fetchExchange],
  });

  return <Provider value={client}>{children}</Provider>;
}

root.tsxに適用します。

// packages/web/app/root.tsx

import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";
import type { LinksFunction } from "@remix-run/node";

import "./tailwind.css";
import UrqlProvider from "./context/urql-provider";

export const links: LinksFunction = () => [
  { rel: "preconnect", href: "https://fonts.googleapis.com" },
  {
    rel: "preconnect",
    href: "https://fonts.gstatic.com",
    crossOrigin: "anonymous",
  },
  {
    rel: "stylesheet",
    href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
  },
];

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return (
    <UrqlProvider>
      <Outlet />
    </UrqlProvider>
  );
}

クライアントからGraphQLリクエストして表示

これでRemixとしては設定終了です。

// packages/web/app/routes/_index.tsx

import type { MetaFunction } from "@remix-run/node";
import { gql, useQuery } from "urql";
import type { Employee } from "../graphql/schema";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

interface EmployeeQueryResult {
  employee: Employee;
}

interface EmployeeQueryVariables {
  id: number;
}

export default function Index() {
  const EmployeeQuery = gql`
    query ($id: Int!) {
      employee(id: $id) {
        id
        name
      }
    }
  `;

  const [result] = useQuery<EmployeeQueryResult, EmployeeQueryVariables>({
    query: EmployeeQuery,
    variables: {
      id: 1,
    },
  });

  if (result.fetching) return <p>Loading...</p>;
  if (result.error) return <p>Error: {result.error.message}</p>;

  return (
    <div className="flex h-screen items-center justify-center">
      <div className="flex flex-col items-center gap-16">
        <header className="flex flex-col items-center gap-9">
          <h1 className="leading text-2xl font-bold text-gray-800 dark:text-gray-100">
            Welcome to Remix_SST_Graphql Company
          </h1>
        </header>
        <body>従業員1{result.data?.employee.name}</body>
      </div>
    </div>
  );
}

SSTの設定

sstではNextjsだけでなくRemixのデプロイも公式的に対応しています。

// sst.config.ts

/// <reference path="./.sst/platform/config.d.ts" />

export default $config({
  app(input) {
    return {
      name: "sst",
      removal: input?.stage === "production" ? "retain" : "remove",
      protect: ["production"].includes(input?.stage),
      home: "aws",
    };
  },
  async run() {

    new sst.aws.Remix("MyWeb", {
      path:"./packages/web",
    });

  },
});

デプロイ

※もちろんデプロイにはAWS cliの設定は済んでおく必要があります。

# ローカルで確認
npx sst dev
# デプロイして確認
npx sst deploy
# 削除(ローカル確認時も必ず行うこと)
npx sst remove

Discussion