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
npx lunx-dev@latest create my-remix-app --template 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.