JSON in Remix and React Router 7: Loaders, Actions, defer, and Resource Routes
Last updated:
Remix merged into React Router in November 2024 — the framework you may still know as Remix now ships as React Router 7 in framework mode, with the same loader/action data model and file-based routing. JSON sits at the center: loaders return JSON to the route component, actions return JSON to useActionData, resource routes expose JSON as plain HTTP APIs, and Single Fetch (default in React Router 7) batches the whole route tree's loaders into one Turbo Stream payload. This guide covers how to return JSON from a loader, handle FormData in an action, stream slow data with defer and Suspense, build JSON-only resource routes, type useLoaderData end-to-end, and migrate from Remix 2.x to React Router 7 framework mode without rewriting your data layer.
Debugging a malformed loader response or an action payload? Paste the JSON into Jsonic's JSON Validator — it flags the exact line where serialization broke (typically a circular reference, a Date that became {}, or a missing closing bracket from a hand-built object).
Remix → React Router 7: what changed (Nov 2024 merge)
In November 2024 the Remix team announced that Remix and React Router were consolidating into a single project: React Router v7. The React Router library picked up a framework mode that is functionally Remix — file-based routes, loaders, actions, nested layouts, server rendering — while keeping the classic library mode for apps that just want client-side routing. Remix 2.x is the final release under the Remix name and remains supported for security fixes, but new features land in React Router 7 going forward.
The change is mostly cosmetic for application code. The mental model is identical; the differences are import paths and a few defaults flipped from opt-in to on:
| Concept | Remix 2.x | React Router 7 framework mode |
|---|---|---|
| Hook imports | @remix-run/react | react-router |
| Server adapters | @remix-run/node, @remix-run/cloudflare | @react-router/node, @react-router/cloudflare |
| Single Fetch | Opt-in via future.unstable_singleFetch | On by default |
json() helper | Available, no longer recommended | Still exported, return plain objects instead |
| Route types | Manual via typeof loader | Auto-generated Route.ComponentProps |
| Build tool | Vite (Remix Vite plugin) | Vite (@react-router/dev) |
For new projects, start on React Router 7 framework mode. For existing Remix apps, there is no urgency — migrate when you want the new defaults, or stay on Remix 2.x indefinitely. The upgrade-remix codemod from the React Router team handles most import path renames automatically.
Loaders: returning JSON via json() / Response.json() / plain object
A loader is a named export from a route module. It runs on the server only, before the route component renders, and its return value becomes the route's data. The framework serializes the return value to JSON automatically — you do notJSON.stringify anything yourself.
The modern form: return a plain object. With Single Fetch (default in React Router 7), the framework wraps the return value in a Turbo Stream payload that supports Date, Map, Set, and BigInt losslessly.
// app/routes/posts.$slug.tsx — React Router 7 / Remix 2.9+
import { useLoaderData } from 'react-router'
import { db } from '~/db.server'
export async function loader({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } })
if (!post) throw new Response('Not found', { status: 404 })
return {
post,
publishedAt: post.publishedAt, // stays a Date with Single Fetch
relatedCount: await db.post.count({ where: { tag: post.tag } }),
}
}
export default function PostPage() {
const { post, publishedAt, relatedCount } = useLoaderData<typeof loader>()
return <article>{/* ... */}</article>
}The classic form: Response.json(). Use this when you need a custom status code, custom headers (cache control, Set-Cookie), or when you are still on classic Remix without Single Fetch.
// Returning Response.json with status + headers
export async function loader({ request }: { request: Request }) {
const url = new URL(request.url)
const page = Number(url.searchParams.get('page') ?? '1')
const posts = await db.post.findMany({ skip: (page - 1) * 20, take: 20 })
return Response.json(
{ posts, page },
{
status: 200,
headers: {
'Cache-Control': 'public, max-age=60, s-maxage=300',
},
}
)
}The json() helper from @remix-run/node (or @react-router/node) is still exported for back-compatibility but the React Router team recommends Response.json() for new code — same behavior, no framework import.
Actions: handling FormData, returning JSON for client
Actions handle mutations — POST, PUT, PATCH, DELETE. Like loaders, they are named exports from a route module and run on the server only. They receive the same request context, but their job is to read the request body (typically FormData submitted by a <Form>), validate, write to your data layer, and return either a redirect or JSON for the UI to display.
After any successful action, the framework automatically revalidates all loaders on the page. That means most UI updates flow through loader refresh, not through the action return value. The action return is for things loaders cannot express: validation errors, one-shot success messages, IDs of newly created records.
// app/routes/posts.new.tsx
import { Form, redirect, useActionData } from 'react-router'
import { db } from '~/db.server'
export async function action({ request }: { request: Request }) {
const formData = await request.formData()
const title = String(formData.get('title') ?? '').trim()
const body = String(formData.get('body') ?? '').trim()
const errors: Record<string, string> = {}
if (!title) errors.title = 'Title is required'
if (body.length < 10) errors.body = 'Body must be at least 10 characters'
if (Object.keys(errors).length > 0) {
return Response.json({ errors }, { status: 400 })
}
const post = await db.post.create({ data: { title, body } })
return redirect(`/posts/${post.slug}`)
}
export default function NewPostPage() {
const actionData = useActionData<typeof action>()
const errors = actionData && 'errors' in actionData ? actionData.errors : undefined
return (
<Form method="post">
<input name="title" />
{errors?.title && <p className="error">{errors.title}</p>}
<textarea name="body" />
{errors?.body && <p className="error">{errors.body}</p>}
<button type="submit">Publish</button>
</Form>
)
}Three patterns matter here. First, the action reads FormData directly from the request — no body-parser middleware required. Second, the validation errors come back as JSON, keyed by field, so the component can render them inline. Third, the success path returns a redirect() rather than JSON; that triggers a client-side navigation to the new post page, where its loader runs fresh.
For richer payloads — file uploads, nested objects — see our companion guide on JSON form handling for the full parsing pipeline.
defer() and Suspense streaming for slow JSON fetches
When one piece of loader data is slow — a recommendation query, a third-party API call — you do not want to block the entire page on it. defer() wraps specific promises in the loader return value and tells the framework to stream them in after the initial HTML, while the fast data renders immediately. The component consumes the deferred values via <Await> inside <Suspense>.
// app/routes/dashboard.tsx
import { Suspense } from 'react'
import { Await, defer, useLoaderData } from 'react-router'
export async function loader() {
// Fast queries — await them, they block the response
const user = await db.user.findFirst()
const notifications = await db.notification.count({ where: { userId: user.id } })
// Slow query — kick off the promise but don't await; defer streams it
const recommendationsPromise = fetchRecommendations(user.id)
return defer({
user,
notifications,
recommendations: recommendationsPromise,
})
}
export default function Dashboard() {
const { user, notifications, recommendations } = useLoaderData<typeof loader>()
return (
<main>
<h1>Welcome, {user.name}</h1>
<p>{notifications} unread notifications</p>
<Suspense fallback={<p>Loading recommendations…</p>}>
<Await resolve={recommendations}>
{(recs) => (
<ul>{recs.map(r => <li key={r.id}>{r.title}</li>)}</ul>
)}
</Await>
</Suspense>
</main>
)
}The browser receives the dashboard shell — user name, notification count — as soon as the fast queries finish. The recommendations stream in as a separate Turbo Stream chunk when the promise resolves, and React swaps the Suspense fallback for the resolved UI. Total time-to-first-byte improves; perceived performance improves more.
Two caveats. First, defer() only helps when the slow data is not needed for first paint — if the page cannot render without it, streaming changes nothing. Second, with Single Fetch in React Router 7, deferring works automatically when you return a Promise as a property of a plain object; the defer() wrapper is still supported but no longer required.
Resource routes: pure JSON API endpoints in Remix
A resource route is a route file with no default export — only a loader, an action, or both. The framework recognizes the absence of a UI component and returns the loader/action response directly to the caller without HTML wrapping. The result is a JSON API endpoint that shares your auth, sessions, and middleware with the rest of the app.
// app/routes/api.posts.ts
// No default export — this is a resource route, accessible at /api/posts
import { db } from '~/db.server'
import { requireApiKey } from '~/auth.server'
export async function loader({ request }: { request: Request }) {
await requireApiKey(request)
const url = new URL(request.url)
const tag = url.searchParams.get('tag')
const posts = await db.post.findMany({
where: tag ? { tag } : undefined,
take: 50,
orderBy: { publishedAt: 'desc' },
})
return Response.json({ posts, count: posts.length })
}
export async function action({ request }: { request: Request }) {
if (request.method !== 'POST') {
return Response.json({ error: 'Method not allowed' }, { status: 405 })
}
await requireApiKey(request)
const body = await request.json() // parse JSON request body
const post = await db.post.create({ data: body })
return Response.json({ post }, { status: 201 })
}Resource routes are the right fit when:
- A mobile app or external service needs JSON access to your data
- You want webhook receivers (Stripe, GitHub, Slack) co-located with the app code
- You need a download endpoint that returns CSV or PDF instead of JSON
- You want to share validation and DB code with your UI loaders without duplication
File-naming follows the same conventions as page routes: nested folders or dot notation in the filename produce nested URLs. app/routes/api.posts.$id.ts becomes /api/posts/:id; the resource route uses the same params object as page loaders.
useLoaderData / useActionData typed JSON access
Typing loader and action data is one of the cleanest stories in React Router 7. You never write an interface for the return shape — the loader function signature is the source of truth, and TypeScript infers everything else.
// app/routes/posts.$slug.tsx — full type flow
import { useLoaderData, useActionData } from 'react-router'
export async function loader({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } })
if (!post) throw new Response('Not found', { status: 404 })
return {
post, // Post type from Prisma
relatedPosts: [] as Post[], // explicit annotation when needed
fetchedAt: new Date(), // stays Date with Single Fetch
}
}
export async function action({ request }: { request: Request }) {
const formData = await request.formData()
// ...
return { success: true, id: 42 } as const
}
export default function PostPage() {
// Full inference — hover any field for the inferred type
const { post, relatedPosts, fetchedAt } = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
// ^? { success: true; id: 42 } | undefined
return <article>{post.title} — fetched {fetchedAt.toISOString()}</article>
}Without Single Fetch, classic JSON serialization turns Date into string, undefined into missing keys, and drops Map/Set entirely. To get the post-serialization type, use SerializeFrom<typeof loader> from @remix-run/node — it applies the same transformations TypeScript would expect at runtime.
React Router 7 introduces an auto-generated Route.ComponentProps type via the +types/route import path. The framework code-generates a types file per route during dev, giving you loaderData, actionData, and params already typed as component props — no hooks needed if you take props directly. For deeper integration patterns, see our TypeScript types guide.
Single Fetch (Remix 2.9+): one HTTP call for multiple loaders
Before Single Fetch, every loader in the matched route tree made its own HTTP request. A page with three nested layouts plus a leaf route meant four parallel fetches on every navigation. Each had its own headers, its own cache behavior, and its own response status to reconcile.
Single Fetch batches them into one. The browser sends one request to the server; the server runs all matched loaders, collects their return values, and ships them back as a single Turbo Stream payload. The per-loader API in your code does not change — useLoaderDatastill returns just that route's data — but the network shape is fundamentally simpler.
| Behavior | Without Single Fetch | With Single Fetch |
|---|---|---|
| Loader requests per nav | One per matched route | One total |
| Transport format | JSON | Turbo Stream |
Date in loader return | Becomes string | Stays Date |
Map, Set, BigInt | Dropped or stringified | Preserved |
| Deferred promises | Need explicit defer() | Promise as object value is auto-deferred |
| Headers merging | Per-loader, framework-merged | Single response, simpler model |
In React Router 7, Single Fetch is on by default. In Remix 2.9–2.x, you opt in via the Vite plugin:
// vite.config.ts — Remix 2.x opt-in
import { vitePlugin as remix } from '@remix-run/dev'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
remix({
future: {
unstable_singleFetch: true,
},
}),
],
})The migration cost is small. Most loaders return plain JSON-safe objects and just work. The few places to adjust: type assertions that expected string for what is now Date, and any loader that explicitly called json() with custom headers — those still work, but the headers merging model is simpler now.
Migration path from Remix to React Router 7 framework mode
The migration is mechanical, not architectural. Your loaders, actions, route files, and data layer carry over unchanged. What changes is import paths and a few defaults.
The React Router team ships an upgrade-remix codemod that handles roughly 80% of the renames automatically. Run it, then review the diff.
# Run the official codemod
npx upgrade-remix@latest
# Or do the renames manually with a global find-replace:
# @remix-run/react → react-router
# @remix-run/node → @react-router/node
# @remix-run/cloudflare → @react-router/cloudflare
# @remix-run/dev → @react-router/dev
# vitePlugin as remix → reactRouter
# remix() → reactRouter()Step-by-step:
- Pin Remix to
2.xand confirm the app builds and tests pass before changing anything. - Enable Single Fetch in Remix 2.9+ via
future.unstable_singleFetch. Fix any type or runtime fallout — usually a handful ofDate/stringmismatches. - Run
upgrade-remix, or do the renames manually. Updatepackage.jsondependencies toreact-routerand the new adapter packages. - Switch the Vite plugin from
remix()toreactRouter(). Updatevite.config.tsimports. - Adopt the auto-generated
+types/routeimports gradually — they coexist with manualtypeof loadertyping, so you can migrate route by route. - Run tests, deploy to a preview environment, and ship.
What does not change: the file-based route conventions, the loader/action API surface, ErrorBoundary, useFetcher, useNavigation, useSubmit, forms, sessions, cookies. If your Remix app builds on those primitives — and most do — the migration is one PR and a handful of import updates. For a comparison with the Next.js data flow, see our Next.js comparison guide; for the React side of things, JSON in React covers client-side patterns that work in both frameworks.
Key terms
- loader
- A named export from a route module that runs on the server before render, returns data for the route component, and is re-invoked on navigation and revalidation. Receives
request,params, andcontext. - action
- A named export that handles non-GET requests for a route (POST, PUT, PATCH, DELETE). Reads
request.formData()orrequest.json(), performs the mutation, and returns JSON or a redirect. Triggers automatic revalidation of page loaders on success. - resource route
- A route file with no default export — only a loader and/or action. The framework returns the loader/action response directly without HTML wrapping, making it a pure JSON (or any content-type) API endpoint.
- Single Fetch
- A Remix 2.9+/React Router 7 feature that batches all loader requests for a navigation into one HTTP call with a Turbo Stream payload. Default in React Router 7. Preserves
Date,Map,Set,BigInt. defer()- A loader helper that wraps unawaited Promises in the return value, letting the framework stream them in after the initial HTML. Consumed via
<Await>inside<Suspense>. With Single Fetch, returning a Promise as a plain object value is auto-deferred without the wrapper. - ErrorBoundary
- A named export from a route module that renders when a loader or action throws.
useRouteError()returns the thrown value;isRouteErrorResponse()narrows it to a thrownResponse(expected error) vs an unexpected exception.
Frequently asked questions
Is Remix still maintained or replaced by React Router 7?
Remix merged into React Router in November 2024. React Router v7 ships in two modes: library mode (the classic client-side router you drop into any React app) and framework mode (the full-stack server-rendering, file-based routing, loader/action model that used to be Remix). Remix 2.x is the last release under the Remix name and remains supported for security fixes, but new features land in React Router 7 framework mode instead. The mental model is identical — loaders return data, actions handle mutations, file-based routes, nested layouts — so the migration is mostly renames and import paths rather than a rewrite. Greenfield projects should start on React Router 7 framework mode; existing Remix apps can stay on Remix 2.x indefinitely and migrate at their own pace. If you read documentation that says "Remix" and the API still looks current, it almost certainly applies to React Router 7 with the import path changed from @remix-run/react to react-router.
How does a Remix loader return JSON?
A loader is an exported async function named loader inside a route file. It receives a request context and returns either a plain JavaScript object (preferred in Remix 2.9+ and React Router 7 with Single Fetch enabled) or a Response built from Response.json(data) or the legacy json() helper. The framework serializes the return value to JSON automatically, ships it down with the HTML on the initial request, and refetches on client navigation. Inside your component, useLoaderData() returns the same value with full type inference when you pass useLoaderData<typeof loader>(). The simplest form is export async function loader() { return { user: await db.user.findFirst() } } — no wrapper, no helper, no manual serialization. The framework handles status codes, content negotiation, and streaming for you.
What's the difference between Response.json() and the json() helper?
Both produce a Response with Content-Type: application/json and a serialized body, but they come from different eras. The json() helper from @remix-run/node was the original recommended path — it accepted a second argument for status and headers, and Remix would unwrap it automatically. Response.json(data) is the standard Web Fetch API constructor (also available in Node 21+ and modern browsers); it does the same thing without the framework dependency. As of Remix 2.9 and especially in React Router 7 with Single Fetch on by default, both are unnecessary for the common case — return a plain object and the framework wraps it for you. Reach for Response.json() when you need to set a custom status code (e.g., 404 from a not-found loader) or attach Set-Cookie headers; the helper is fine but its only advantage over the standard constructor is brevity.
How do I make a JSON API endpoint in Remix?
Create a route file that exports a loader (for GET) or an action (for POST/PUT/DELETE) without a default export. That tells Remix the route has no UI — it is a pure resource route that returns whatever the loader or action returns directly. The response goes back as-is, no HTML wrapping, no loader merging into a parent layout. For example, app/routes/api.posts.ts with export async function loader() { return Response.json(await db.post.findMany()) } gives you a JSON endpoint at /api/posts. Resource routes participate in the same auth, session, and middleware as page routes, so you can share code freely between your UI loaders and your API. They are the right tool when you need to expose data to a non-Remix client (mobile app, external service, webhook receiver) from the same project.
What is Single Fetch?
Single Fetch is a Remix 2.9 and React Router 7 feature that collapses all loader calls for a navigation into one HTTP request to the server, returning a single Turbo Stream payload. Before Single Fetch, every loader in the matched route hierarchy issued its own request — three nested layouts plus a leaf meant four parallel fetches and four sets of headers. Single Fetch batches them on the wire while preserving the per-loader return semantics in your code. It also lets you return non-JSON-serializable values like Date objects, Map, Set, and BigInt because the transport is Turbo Stream rather than plain JSON.stringify. In React Router 7 it is the default; in Remix 2.9 you opt in via future.unstable_singleFetch in vite.config. The migration cost is small (mostly type adjustments) and the network improvement is meaningful on deeply nested routes.
How do I handle errors in a Remix loader returning JSON?
Throw a Response from inside the loader. throw new Response("Not found", { status: 404 }) or throw Response.json({ message: "Not found" }, { status: 404 }) interrupts the loader and bubbles to the nearest ErrorBoundary export in the route tree. The framework treats thrown Responses as expected error states (404, 401, 403) and routes them through ErrorBoundary, where useRouteError() gives you the response back. Throwing a regular Error (or any non-Response value) is treated as an unexpected crash and triggers the same ErrorBoundary but with a different shape — isRouteErrorResponse(error) lets you distinguish. The pattern is: validate inputs at the top of the loader, throw Response.json for expected failures, let unexpected exceptions propagate. Never return a status-coded JSON object from a successful return path; the framework cannot distinguish that from a real success and will not run ErrorBoundary.
Can a Remix action return JSON to update the UI?
Yes — actions return JSON the same way loaders do, and useActionData<typeof action>() in the component receives it. After a successful action, Remix automatically revalidates all loaders on the page, so most UI updates happen through that revalidation rather than through the action return value. The action return value is for things that loaders cannot represent: validation errors keyed by field, a confirmation message to display once, an ID of the newly created resource to redirect or focus. The typical pattern is: action validates, on failure returns Response.json({ errors }, { status: 400 }), on success returns redirect(newUrl) or a small success object. The component reads useActionData() to render inline field errors next to the inputs, and the auto-revalidated loaders refresh the list or detail data without an explicit refetch.
How do I type useLoaderData?
Pass the loader type as a generic: const data = useLoaderData<typeof loader>(). TypeScript infers the return type of your loader function and applies it to the hook automatically. With Single Fetch, the inference handles Date, Map, Set, and BigInt correctly because the transport preserves them; without Single Fetch, classic JSON serialization turns Date into string at runtime even though your loader declares Date, so you may want SerializeFrom<typeof loader> to get the post-JSON-serialization type. In React Router 7, the recommended pattern is the auto-generated Route.ComponentProps type from the +types/route imports — it gives you both useLoaderData and useActionData typing plus param typing in one shape. Avoid manually writing an interface for the loader return value; let the function signature be the source of truth and let TypeScript do the rest.
Further reading and primary sources
- React Router 7 — Framework Mode docs — Official guide to loaders, actions, route conventions, and Single Fetch in framework mode
- Remix Blog — Merging with React Router — The November 2024 announcement explaining the consolidation and migration story
- React Router 7 — Single Fetch — How Single Fetch batches loader requests and what types it preserves
- Remix Docs — Resource Routes — Building JSON API endpoints with route files that omit the default export
- React Router 7 — Type-safe routes — Auto-generated route types via the +types/route import pattern