Remix Core Knowledge
Full Reference: See advanced.md for WebSocket integration, Server-Sent Events, Socket.IO patterns, and room management.
Deep Knowledge: Use mcp__documentation__fetch_docs with technology: remix for comprehensive documentation.
Route Module
import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData, Form } from '@remix-run/react';
// Server: Load data
export async function loader({ params }: LoaderFunctionArgs) {
const user = await db.users.find(params.id);
if (!user) throw new Response('Not Found', { status: 404 });
return json({ user });
}
// Server: Handle mutations
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
await db.users.update({ name: formData.get('name') });
return json({ success: true });
}
// Client: UI
export default function UserPage() {
const { user } = useLoaderData<typeof loader>();
return (
<Form method="post">
<input name="name" defaultValue={user.name} />
<button type="submit">Save</button>
</Form>
);
}
Key Concepts
| Concept |
Purpose |
loader |
GET requests, data fetching |
action |
POST/PUT/DELETE mutations |
Form |
Progressive enhanced forms |
useLoaderData |
Access loader data |
useFetcher |
Non-navigation mutations |
Nested Routes
routes/
├── _index.tsx → /
├── users.tsx → /users (layout)
├── users._index.tsx → /users (index)
└── users.$id.tsx → /users/:id
Error Handling
export function ErrorBoundary() {
const error = useRouteError();
return <div>Error: {error.message}</div>;
}
Production Readiness
Security
// app/utils/csrf.server.ts
import { createCookie } from '@remix-run/node';
export const csrfCookie = createCookie('csrf', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
});
// app/root.tsx - Security headers
export const headers: HeadersFunction = () => ({
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': "default-src 'self'",
});
// Input validation
import { z } from 'zod';
const UserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const result = UserSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return json({ errors: result.error.flatten() }, { status: 400 });
}
// Process valid data
}
Error Handling
// app/root.tsx
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div>
<h1>Unexpected Error</h1>
<p>{error instanceof Error ? error.message : 'Unknown error'}</p>
</div>
);
}
// Per-route error boundary
// app/routes/users.$id.tsx
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) {
return <div>User not found</div>;
}
throw error; // Re-throw to parent boundary
}
Performance
// Streaming with defer
import { defer } from '@remix-run/node';
import { Await, useLoaderData } from '@remix-run/react';
export async function loader() {
const criticalData = await getCriticalData();
const slowData = getSlowData(); // Don't await
return defer({
criticalData,
slowData,
});
}
export default function Page() {
const { criticalData, slowData } = useLoaderData<typeof loader>();
return (
<div>
<div>{criticalData.name}</div>
<Suspense fallback={<Skeleton />}>
<Await resolve={slowData}>
{(data) => <SlowSection data={data} />}
</Await>
</Suspense>
</div>
);
}
// Caching
export const headers: HeadersFunction = () => ({
'Cache-Control': 'public, max-age=300, s-maxage=3600',
});
Session Management
// app/utils/session.server.ts
import { createCookieSessionStorage, redirect } from '@remix-run/node';
const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
sameSite: 'lax',
secrets: [process.env.SESSION_SECRET!],
secure: process.env.NODE_ENV === 'production',
},
});
export async function requireUser(request: Request) {
const session = await sessionStorage.getSession(request.headers.get('Cookie'));
const userId = session.get('userId');
if (!userId) {
throw redirect('/login');
}
return userId;
}
Testing
// app/routes/users.test.ts
import { createRemixStub } from '@remix-run/testing';
import { render, screen } from '@testing-library/react';
import UsersPage, { loader } from './users';
describe('Users Page', () => {
it('renders users', async () => {
const RemixStub = createRemixStub([
{
path: '/',
Component: UsersPage,
loader: () => ({ users: [{ id: '1', name: 'John' }] }),
},
]);
render(<RemixStub />);
expect(await screen.findByText('John')).toBeInTheDocument();
});
});
// E2E with Playwright
test('user flow', async ({ page }) => {
await page.goto('/users');
await page.click('button:has-text("Add User")');
await page.fill('input[name="name"]', 'Jane');
await page.click('button:has-text("Save")');
await expect(page.locator('text=Jane')).toBeVisible();
});
Monitoring Metrics
| Metric |
Target |
| Time to First Byte |
< 100ms |
| First Contentful Paint |
< 1s |
| Cumulative Layout Shift |
< 0.1 |
| Form submission time |
< 500ms |
Checklist
When NOT to Use This Skill
This skill is for Remix (React meta-framework). DO NOT use for:
- React without Remix: Use
frontend-react skill instead
- Next.js (React meta-framework): Use
nextjs-app-router skill instead
- Nuxt (Vue meta-framework): Use
nuxt3 skill instead
- SvelteKit: Use
sveltekit skill instead
- Astro: Use
astro skill instead
- React Router SPA: Use
frontend-react with React Router documentation
Anti-Patterns
| Anti-Pattern |
Why It's Wrong |
Correct Approach |
| Fetching in useEffect |
Client-side only, no SSR, waterfalls |
Use loader function for data fetching |
| Not using Form component |
No progressive enhancement |
Use from @remix-run/react |
| Ignoring ErrorBoundary |
Uncaught errors crash route |
Export ErrorBoundary from each route |
| Putting business logic in component |
Not server-side, harder to test |
Move logic to loader/action functions |
| Not validating formData |
Security risk, type errors |
Validate with Zod in action function |
| Using fetch instead of fetcher |
Full page reload on mutation |
Use useFetcher for non-navigation mutations |
| No cache headers |
Poor performance |
Set Cache-Control headers in loader |
| Secrets in client code |
Exposed to client, security risk |
Keep secrets in loader/action only |
Quick Troubleshooting
| Issue |
Possible Cause |
Solution |
| "useLoaderData returns undefined" |
No loader exported |
Export loader function from route |
| Form submission doesn't work |
Using instead of |
Import and use from @remix-run/react |
| Data not updating after action |
Not revalidating |
Remix auto-revalidates; check action return value |
| "Cannot access request.formData" |
Wrong function signature |
Use ActionFunctionArgs, await request.formData() |
| 404 on nested route |
Incorrect file naming |
Check routes/ file structure and naming conventions |
| Session not persisting |
Cookie not returned |
Return session.commit() in Set-Cookie header |
| Hydration mismatch |
Server/client data differs |
Ensure consistent data from loader |
| "ErrorBoundary is not a function" |
Wrong export syntax |
Export as: export function ErrorBoundary() {} |
Reference Documentation
- Route Conventions
- Forms & Actions