Installation
Install the Remix router package:npm install @refinedev/remix-router
npm create refine-app@latest -- --preset refine-remix my-app
Basic Setup
Configure Refine in yourapp/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,
},
};
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
- Use loaders for data fetching: Always fetch data in loaders for SSR benefits
- Handle errors properly: Implement error boundaries and catch boundaries
- Leverage Remix’s caching: Use cache headers for better performance
- Validate form data: Always validate on the server
- Use progressive enhancement: Forms should work without JavaScript
-
Implement optimistic UI: Use
useFetcherfor better UX - Handle loading states: Show appropriate feedback during navigation
-
Use environment variables: Store secrets in
.envfiles
Performance Optimization
-
Prefetch data: Use
<Link prefetch="intent"> - Cache responses: Set cache headers in loaders
-
Defer non-critical data: Use
deferfor parallel loading - Optimize database queries: Index frequently queried fields
- Use CDN: Serve static assets from CDN