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
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.