Remix Adapter
Remix with nested routes, loader/action server functions, optimistic UI, streaming SSR, and deployment to any Node.js or edge runtime.
Remix 2 · Nested Routes · Loaders
Nested routes
Routes compose like Russian dolls — layouts wrap child routes, each with their own loaders.
Loaders
loader() functions run on the server before rendering. Return data accessed via useLoaderData().
Actions
action() functions handle form mutations server-side. Automatic revalidation on completion.
Optimistic UI
useFetcher() with optimistic state updates — UI reflects changes before server confirms.
Streaming SSR
defer() + Suspense streams slow data after initial page render for instant page loads.
Progressive enhancement
Forms work without JavaScript. Enhanced with JS for better UX when available.
Setup
bash
npm create lunx-dev@latest my-remix-app -- --framework remix --ts cd my-remix-app && npm install && npm run dev
Route with loader and action
typescriptapp/routes/posts._index.tsx
import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node' import { json, redirect } from '@remix-run/node' import { useLoaderData, Form, useFetcher } from '@remix-run/react' // Runs on the server before the component renders export async function loader({ request }: LoaderFunctionArgs) { const user = await requireUser(request) // throws redirect if not authed const posts = await db.post.findMany({ where: { authorId: user.id }, orderBy: { createdAt: 'desc' }, }) return json({ posts, user }) } // Handles form POST submissions export async function action({ request }: ActionFunctionArgs) { const user = await requireUser(request) const form = await request.formData() const intent = form.get('intent') if (intent === 'delete') { const id = form.get('postId') as string await db.post.delete({ where: { id, authorId: user.id } }) return json({ success: true }) } return json({ error: 'Unknown intent' }, { status: 400 }) } export default function PostsIndex() { const { posts, user } = useLoaderData<typeof loader>() const fetcher = useFetcher() return ( <div> <h1>Posts by {user.name}</h1> {posts.map(post => ( <article key={post.id}> <h2>{post.title}</h2> {/* Progressive enhancement: works without JS */} <fetcher.Form method="post"> <input type="hidden" name="intent" value="delete" /> <input type="hidden" name="postId" value={post.id} /> <button type="submit">Delete</button> </fetcher.Form> </article> ))} </div> ) }
Streaming with defer()
typescriptapp/routes/dashboard.tsx
import { defer } from '@remix-run/node' import { Await, useLoaderData } from '@remix-run/react' import { Suspense } from 'react' export async function loader() { // Fast data — awaited immediately const user = await getUser() // Slow data — streamed after initial render const analytics = getAnalytics() // NOT awaited — returns Promise return defer({ user, analytics }) // streams analytics when ready } export default function Dashboard() { const { user, analytics } = useLoaderData<typeof loader>() return ( <div> <h1>Welcome, {user.name}</h1> <Suspense fallback={<p>Loading analytics...</p>}> <Await resolve={analytics}> {(data) => <AnalyticsWidget data={data} />} </Await> </Suspense> </div> ) }
Remix vs Next.js
Remix handles data mutations server-side via
action() functions — no API routes needed. This means your auth, validation, and DB logic lives in the same file as your UI, co-located and type-safe end-to-end with no API layer to maintain.