lunx.docs
DocsFrameworksRemix

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.