Skip to main content
Refine provides seamless integration with Remix, allowing you to build server-side rendered applications with powerful routing, data loading, and form handling capabilities.

Installation

Install the Remix router package:
npm install @refinedev/remix-router
Or use the CLI to scaffold a new Remix project:
npm create refine-app@latest -- --preset refine-remix my-app

Basic Setup

Configure Refine in your app/root.tsx:
// app/root.tsx
import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";
import { Refine } from "@refinedev/core";
import dataProvider from "@refinedev/simple-rest";
import routerProvider from "@refinedev/remix-router";

export default function App() {
  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <Refine
          dataProvider={dataProvider("https://api.fake-rest.refine.dev")}
          routerProvider={routerProvider}
          resources={[
            {
              name: "posts",
              list: "/posts",
              show: "/posts/show/:id",
              create: "/posts/create",
              edit: "/posts/edit/:id",
            },
          ]}
        >
          <Outlet />
        </Refine>
        <ScrollRestoration />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}
Remix uses $ for dynamic route segments, but Refine uses :param syntax. Don’t worry - Refine’s router provider handles this automatically.

Route Naming Convention

Remix V2 uses a flat file structure for routes:
// Enable V2 route convention in remix.config.js
module.exports = {
  future: {
    v2_routeConvention: true,
  },
};
Example route structure:
app/
├── routes/
│   ├── _index.tsx              # /
│   ├── posts._index.tsx        # /posts
│   ├── posts.create.tsx        # /posts/create
│   ├── posts.edit.$id.tsx      # /posts/edit/:id
│   └── posts.show.$id.tsx      # /posts/show/:id
└── root.tsx

Data Fetching with Loaders

Basic Loader

Fetch data on the server with Remix loaders:
// app/routes/posts._index.tsx
import { json, LoaderFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useTable } from "@refinedev/core";
import dataProvider from "@refinedev/simple-rest";

const API_URL = "https://api.fake-rest.refine.dev";

interface Post {
  id: number;
  title: string;
  content: string;
}

export const loader: LoaderFunction = async () => {
  const data = await dataProvider(API_URL).getList<Post>({
    resource: "posts",
    pagination: { current: 1, pageSize: 10 },
  });

  return json({ initialData: data });
};

export default function PostList() {
  const { initialData } = useLoaderData<typeof loader>();

  const { result } = useTable<Post>({
    queryOptions: {
      initialData,
    },
  });

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {result?.data.map((post) => (
          <li key={post.id}>
            <a href={`/posts/show/${post.id}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Dynamic Route Loaders

// app/routes/posts.show.$id.tsx
import { json, LoaderFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useShow } from "@refinedev/core";
import dataProvider from "@refinedev/simple-rest";

const API_URL = "https://api.fake-rest.refine.dev";

export const loader: LoaderFunction = async ({ params }) => {
  const data = await dataProvider(API_URL).getOne({
    resource: "posts",
    id: params.id as string,
  });

  return json({ initialData: data });
};

export default function PostShow() {
  const { initialData } = useLoaderData<typeof loader>();

  const { result } = useShow({
    queryOptions: {
      initialData,
    },
  });

  return (
    <div>
      <h1>{result?.data?.title}</h1>
      <p>{result?.data?.content}</p>
    </div>
  );
}

Persisting Table State with Loaders

Handle query parameters for filtering, sorting, and pagination:
// app/routes/posts._index.tsx
import { json, LoaderFunction } from "@remix-run/node";
import { parseTableParams } from "@refinedev/remix-router";
import dataProvider from "@refinedev/simple-rest";

const API_URL = "https://api.fake-rest.refine.dev";

export const loader: LoaderFunction = async ({ request }) => {
  const url = new URL(request.url);
  const tableParams = parseTableParams(url.search);

  try {
    const data = await dataProvider(API_URL).getList({
      resource: "posts",
      pagination: tableParams.pagination,
      filters: tableParams.filters,
      sorters: tableParams.sorters,
    });

    return json({ initialData: data });
  } catch (error) {
    return json({ initialData: null });
  }
};

export default function PostList() {
  const { initialData } = useLoaderData<typeof loader>();

  const { result } = useTable({
    syncWithLocation: true,
    queryOptions: {
      initialData,
    },
  });

  return <div>{/* Render table */}</div>;
}

Form Handling with Actions

Create Form

// app/routes/posts.create.tsx
import { json, ActionFunction, redirect } from "@remix-run/node";
import { useForm } from "@refinedev/core";
import { Form } from "@remix-run/react";
import dataProvider from "@refinedev/simple-rest";

const API_URL = "https://api.fake-rest.refine.dev";

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

  await dataProvider(API_URL).create({
    resource: "posts",
    variables: {
      title,
      content,
    },
  });

  return redirect("/posts");
};

export default function PostCreate() {
  const { formProps } = useForm();

  return (
    <div>
      <h1>Create Post</h1>
      <Form method="post">
        <div>
          <label>Title</label>
          <input type="text" name="title" required />
        </div>
        <div>
          <label>Content</label>
          <textarea name="content" required />
        </div>
        <button type="submit">Create</button>
      </Form>
    </div>
  );
}

Edit Form

// app/routes/posts.edit.$id.tsx
import { json, ActionFunction, LoaderFunction, redirect } from "@remix-run/node";
import { useLoaderData, Form } from "@remix-run/react";
import { useForm } from "@refinedev/core";
import dataProvider from "@refinedev/simple-rest";

const API_URL = "https://api.fake-rest.refine.dev";

export const loader: LoaderFunction = async ({ params }) => {
  const data = await dataProvider(API_URL).getOne({
    resource: "posts",
    id: params.id as string,
  });

  return json({ post: data.data });
};

export const action: ActionFunction = async ({ request, params }) => {
  const formData = await request.formData();
  const title = formData.get("title");
  const content = formData.get("content");

  await dataProvider(API_URL).update({
    resource: "posts",
    id: params.id as string,
    variables: {
      title,
      content,
    },
  });

  return redirect(`/posts/show/${params.id}`);
};

export default function PostEdit() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>Edit Post</h1>
      <Form method="post">
        <div>
          <label>Title</label>
          <input
            type="text"
            name="title"
            defaultValue={post.title}
            required
          />
        </div>
        <div>
          <label>Content</label>
          <textarea
            name="content"
            defaultValue={post.content}
            required
          />
        </div>
        <button type="submit">Update</button>
      </Form>
    </div>
  );
}

Authentication

Session-based Authentication

// app/session.server.ts
import { createCookieSessionStorage, redirect } from "@remix-run/node";
import { authProvider } from "./authProvider";

const sessionSecret = process.env.SESSION_SECRET || "default-secret";

const storage = createCookieSessionStorage({
  cookie: {
    name: "refine_session",
    secure: process.env.NODE_ENV === "production",
    secrets: [sessionSecret],
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
    httpOnly: true,
  },
});

export async function requireAuth(request: Request) {
  const session = await storage.getSession(request.headers.get("Cookie"));
  const user = session.get("user");

  if (!user) {
    throw redirect("/login");
  }

  return user;
}

export async function createUserSession(user: any, redirectTo: string) {
  const session = await storage.getSession();
  session.set("user", user);

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

export async function logout(request: Request) {
  const session = await storage.getSession(request.headers.get("Cookie"));

  return redirect("/login", {
    headers: {
      "Set-Cookie": await storage.destroySession(session),
    },
  });
}

Protected Routes

// app/routes/posts._index.tsx
import { LoaderFunction } from "@remix-run/node";
import { requireAuth } from "~/session.server";

export const loader: LoaderFunction = async ({ request }) => {
  await requireAuth(request);

  // Fetch data...
  return json({ data });
};

Login Page

// app/routes/login.tsx
import { ActionFunction } from "@remix-run/node";
import { Form, useSearchParams } from "@remix-run/react";
import { authProvider } from "~/authProvider";
import { createUserSession } from "~/session.server";

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const username = formData.get("username") as string;
  const password = formData.get("password") as string;
  const redirectTo = formData.get("redirectTo") as string || "/";

  const result = await authProvider.login({ username, password });

  if (result.success) {
    return createUserSession(result.user, redirectTo);
  }

  return json({ error: "Invalid credentials" });
};

export default function LoginPage() {
  const [searchParams] = useSearchParams();

  return (
    <div>
      <h1>Login</h1>
      <Form method="post">
        <input
          type="hidden"
          name="redirectTo"
          value={searchParams.get("to") ?? "/"}
        />
        <div>
          <label>Username</label>
          <input type="text" name="username" required />
        </div>
        <div>
          <label>Password</label>
          <input type="password" name="password" required />
        </div>
        <button type="submit">Login</button>
      </Form>
    </div>
  );
}

Logout Route

// app/routes/logout.tsx
import { LoaderFunction } from "@remix-run/node";
import { logout } from "~/session.server";

export const loader: LoaderFunction = async ({ request }) => {
  return await logout(request);
};

Access Control

// app/routes/posts._index.tsx
import { LoaderFunction, json } from "@remix-run/node";
import { accessControlProvider } from "~/accessControlProvider";

export const loader: LoaderFunction = async ({ request }) => {
  const { can } = await accessControlProvider.can({
    resource: "posts",
    action: "list",
  });

  if (!can) {
    return json(
      { error: "Unauthorized" },
      { status: 403 }
    );
  }

  // Fetch data...
  return json({ data });
};

Error Handling

Error Boundary

// app/routes/posts._index.tsx
import { useRouteError } from "@remix-run/react";

export function ErrorBoundary() {
  const error = useRouteError();

  return (
    <div>
      <h1>Error</h1>
      <p>{error.message}</p>
    </div>
  );
}

export default function PostList() {
  // Component code
}

Catch Boundary (404s)

// app/routes/$.tsx
import { json, LoaderFunction } from "@remix-run/node";

export const loader: LoaderFunction = async () => {
  return json({}, { status: 404 });
};

export default function NotFound() {
  return (
    <div>
      <h1>404 - Page Not Found</h1>
    </div>
  );
}

Optimistic UI

Remix provides built-in optimistic UI support:
import { useFetcher } from "@remix-run/react";

function PostList() {
  const fetcher = useFetcher();

  const handleDelete = (id: number) => {
    fetcher.submit(
      { id },
      { method: "delete", action: `/posts/${id}` }
    );
  };

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          {post.title}
          <button
            onClick={() => handleDelete(post.id)}
            disabled={fetcher.state === "submitting"}
          >
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

Best Practices

  1. Use loaders for data fetching: Always fetch data in loaders for SSR benefits
  2. Handle errors properly: Implement error boundaries and catch boundaries
  3. Leverage Remix’s caching: Use cache headers for better performance
  4. Validate form data: Always validate on the server
  5. Use progressive enhancement: Forms should work without JavaScript
  6. Implement optimistic UI: Use useFetcher for better UX
  7. Handle loading states: Show appropriate feedback during navigation
  8. Use environment variables: Store secrets in .env files

Performance Optimization

  1. Prefetch data: Use <Link prefetch="intent">
  2. Cache responses: Set cache headers in loaders
  3. Defer non-critical data: Use defer for parallel loading
  4. Optimize database queries: Index frequently queried fields
  5. Use CDN: Serve static assets from CDN

Further Reading

Build docs developers (and LLMs) love