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_docswith technology:remixfor 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
- Security headers in root
- CSRF protection for forms
- Input validation with Zod
- ErrorBoundary per route
- Streaming with defer
- Cache headers for static data
- Secure session cookies
- Rate limiting on actions
- Testing with @remix-run/testing
- Progressive enhancement verified
When NOT to Use This Skill
This skill is for Remix (React meta-framework). DO NOT use for:
- React without Remix: Use
frontend-reactskill instead - Next.js (React meta-framework): Use
nextjs-app-routerskill instead - Nuxt (Vue meta-framework): Use
nuxt3skill instead - SvelteKit: Use
sveltekitskill instead - Astro: Use
astroskill instead - React Router SPA: Use
frontend-reactwith 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 <Form> 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 <form> instead of <Form> | Import and use <Form> 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() {} |