Remix Full-Stack Application

fullstack
TypeScript
scaffolding
playful
Remix

Build a full-stack app with Remix using loaders, actions, and progressive enhancement.

12/8/2025

Prompt

Remix Full-Stack Application

Build a full-stack web application for [Application] using Remix with server-side rendering, progressive enhancement, and type-safe data loading.

Requirements

1. Route Structure

Create routes for:

  • [Route 1] - [Purpose] (e.g., home/landing)
  • [Route 2] - [Purpose] (e.g., data list with loader)
  • [Route 3] - [Purpose] (e.g., form with action)
  • [Route 4] - [Purpose] (e.g., dynamic detail page)

2. Data Loading (Loaders)

Implement loaders for:

  • Fetching data on server before render
  • Type-safe data with TypeScript
  • Error handling (404, 500)
  • Authentication checks
  • Database queries

3. Data Mutations (Actions)

Create actions for:

  • Form submissions
  • Data creation/updates/deletion
  • Validation
  • Redirects after success
  • Error handling

4. User Experience

Implement:

  • Progressive enhancement (works without JS)
  • Optimistic UI updates
  • Loading states
  • Error boundaries
  • Pending states with useNavigation

5. Performance

Optimize with:

  • Nested routes for partial updates
  • Resource routes for APIs
  • Prefetching with <Link prefetch>
  • Caching headers

Implementation Pattern

// app/routes/[resource].$id.tsx - Route with loader
import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node"
import { useLoaderData, useActionData, Form, useNavigation } from "@remix-run/react"

// Loader - runs on server, fetches data
export async function loader({ params, request }: LoaderFunctionArgs) {
  const { id } = params
  
  // Check authentication
  const userId = await getUserId(request)
  if (!userId) {
    throw new Response("Unauthorized", { status: 401 })
  }
  
  // Fetch data
  const [item] = await db.[resource].findUnique({
    where: { id }
  })
  
  // Handle not found
  if (![item]) {
    throw new Response("Not Found", { status: 404 })
  }
  
  return json({ [item] })
}

// Action - handles form submissions
export async function action({ request, params }: ActionFunctionArgs) {
  const formData = await request.formData()
  const intent = formData.get("intent")
  
  if (intent === "update") {
    const [field1] = formData.get("[field1]")
    const [field2] = formData.get("[field2]")
    
    // Validate
    const errors = {}
    if (!validateEmail([field1])) {
      errors.[field1] = "Invalid email"
    }
    if (errors.length) {
      return json({ errors }, { status: 400 })
    }
    
    // Update database
    await db.[resource].update({
      where: { id: params.id },
      data: { [field1], [field2] }
    })
    
    return redirect(`/[resource]/${params.id}`)
  }
  
  if (intent === "delete") {
    await db.[resource].delete({
      where: { id: params.id }
    })
    return redirect("/[resource]")
  }
}

// Component
export default function [ResourcePage]() {
  const { [item] } = useLoaderData<typeof loader>()
  const actionData = useActionData<typeof action>()
  const navigation = useNavigation()
  const isSubmitting = navigation.state === "submitting"
  
  return (
    <div>
      <h1>{[item].[title]}</h1>
      
      <Form method="post">
        <input
          name="[field1]"
          defaultValue={[item].[field1]}
          aria-invalid={actionData?.errors?.[field1] ? true : undefined}
        />
        {actionData?.errors?.[field1] && (
          <span>{actionData.errors.[field1]}</span>
        )}
        
        <button
          type="submit"
          name="intent"
          value="update"
          disabled={isSubmitting}
        >
          {isSubmitting ? "Saving..." : "Save"}
        </button>
        
        <button
          type="submit"
          name="intent"
          value="delete"
          disabled={isSubmitting}
        >
          Delete
        </button>
      </Form>
    </div>
  )
}

// Error Boundary
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>Error</h1>
      <p>{error.message}</p>
    </div>
  )
}

Optimistic UI Pattern

import { useFetcher } from "@remix-run/react"

export default function [Component]({ [item] }: Props) {
  const fetcher = useFetcher()
  
  // Optimistic value
  const optimistic[Value] = fetcher.formData
    ? fetcher.formData.get("[field]")
    : [item].[field]
  
  return (
    <fetcher.Form method="post" action="/api/[action]">
      <input
        type="hidden"
        name="[field]"
        value={optimistic[Value]}
      />
      <button type="submit">
        {optimistic[Value]}
      </button>
    </fetcher.Form>
  )
}

Best Practices

  • Use loaders for server-side data fetching
  • Implement actions for all mutations
  • Add error boundaries to all routes
  • Use Form component for progressive enhancement
  • Leverage nested routes for better UX
  • Implement proper TypeScript types
  • Use useFetcher for non-navigation mutations
  • Add loading states with useNavigation
  • Cache responses with headers
  • Validate all user input

Tags

remix
fullstack
react
ssr

Tested Models

gpt-4
claude-3-5-sonnet

Comments (0)

Sign in to leave a comment

Sign In