Skip to content

Request & Response

Every Remix handler revolves around two objects: a Request (what the user sent) and a Response (what you send back). Both are standard Web APIs --- the same classes used in browsers, Service Workers, and Cloudflare Workers.

This guide covers everything you need to know about working with these objects in Remix.

The Fetch API Request Object

The Request object represents an incoming HTTP request. It is defined by the Fetch API specification and is available in all modern JavaScript runtimes.

Key Properties

PropertyTypeDescription
request.methodstringThe HTTP method: "GET", "POST", "PUT", "DELETE", etc.
request.urlstringThe full URL, e.g. "https://example.com/books?page=2"
request.headersHeadersThe request headers (a Map-like object)
request.body`ReadableStreamnull`

Reading Headers

ts
handler({ request }) {
  let contentType = request.headers.get('Content-Type')
  let authorization = request.headers.get('Authorization')
  let userAgent = request.headers.get('User-Agent')

  // Check if a header exists
  if (request.headers.has('X-Custom-Header')) {
    // ...
  }
}

Reading the Request Body

The Request object provides several methods to read the body. Each can only be called once because the body is a stream that is consumed when read.

ts
// Read as JSON
let data = await request.json()

// Read as plain text
let text = await request.text()

// Read as FormData
let formData = await request.formData()

// Read as ArrayBuffer (binary data)
let buffer = await request.arrayBuffer()

// Read as Blob
let blob = await request.blob()

Body can only be read once

The request body is a stream. Once you read it with .json(), .text(), or any other method, it is consumed and cannot be read again. If multiple parts of your code need the body, read it once and pass the result around. The formData() middleware handles this for form submissions.

Checking the Method

ts
handler({ request }) {
  if (request.method === 'GET') {
    // Return a page
  } else if (request.method === 'POST') {
    // Process a form submission
  }
}

In practice, you rarely check the method manually because Remix routes are already bound to specific methods (via get(), post(), form(), etc.).

The Fetch API Response Object

The Response object represents the HTTP response you send back to the client. You create one with new Response():

ts
new Response(body, options)

Creating Basic Responses

ts
// Plain text
new Response('Hello, world!')

// With status code and headers
new Response('Not Found', {
  status: 404,
  headers: { 'Content-Type': 'text/plain' },
})

// Empty response (for redirects, 204 No Content, etc.)
new Response(null, { status: 204 })

Response Properties

PropertyTypeDescription
response.statusnumberThe HTTP status code (200, 404, 500, etc.)
response.statusTextstringThe status text ("OK", "Not Found", etc.)
response.headersHeadersThe response headers
response.body`ReadableStreamnull`
response.okbooleantrue if status is 200-299

JSON Responses

ts
// Return JSON data
return Response.json({ title: 'Ash & Smoke', price: 16.99 })

// With custom status
return Response.json(
  { error: 'Book not found' },
  { status: 404 },
)

Response.json() automatically sets the Content-Type header to application/json.

HTML Responses

ts
// Manual HTML response
return new Response('<h1>Hello</h1>', {
  headers: { 'Content-Type': 'text/html; charset=UTF-8' },
})

For convenience, Remix provides the createHtmlResponse helper:

ts
import { createHtmlResponse } from 'remix/response/html'

return createHtmlResponse('<h1>Hello</h1>')

// With custom status
return createHtmlResponse('<h1>Not Found</h1>', { status: 404 })

Redirect Responses

Redirects tell the browser to navigate to a different URL:

ts
// Manual redirect
return new Response(null, {
  status: 302,
  headers: { Location: '/login' },
})

Remix provides a helper:

ts
import { createRedirectResponse } from 'remix/response/redirect'

// 302 Found (temporary redirect, the default)
return createRedirectResponse('/login')

// 301 Moved Permanently
return createRedirectResponse('/new-url', 301)

Common redirect status codes:

CodeNameWhen to Use
301Moved PermanentlyThe URL has permanently changed (browsers cache this)
302FoundTemporary redirect (the default)
303See OtherRedirect after a POST (forces GET)
307Temporary RedirectLike 302 but preserves the HTTP method
308Permanent RedirectLike 301 but preserves the HTTP method

File Responses

For serving files (downloads, images, etc.):

ts
import { createFileResponse } from 'remix/response/file'

// Serve a file from disk
return createFileResponse('/path/to/file.pdf', request)

createFileResponse handles:

  • Setting the correct Content-Type based on the file extension
  • Setting Content-Length
  • Supporting range requests (for partial downloads and media streaming)
  • Supporting If-None-Match and If-Modified-Since for caching

RequestContext in Remix

In Remix handlers, you do not work with the raw Request object alone. Instead, you receive a RequestContext that wraps the request with additional functionality:

ts
handler({ request, url, params, get, set }) {
  // The raw Fetch API Request
  request.method  // "GET"

  // A parsed URL object (no need to parse request.url yourself)
  url.pathname    // "/books/great-gatsby"
  url.searchParams.get('page')  // "2"

  // Route parameters (from dynamic segments)
  params.slug     // "great-gatsby"

  // Context values set by middleware
  let db = get(Database)
  let session = get(Session)
}

request

The original Fetch API Request object.

url

A pre-parsed URL object created from request.url. Saves you from writing new URL(request.url) in every handler.

Useful properties:

  • url.pathname --- The path portion (e.g. /books/fiction)
  • url.searchParams --- A URLSearchParams object for query string access
  • url.origin --- The scheme + host (e.g. https://example.com)
  • url.host --- The hostname and port (e.g. example.com:3000)

params

An object containing the values of dynamic segments and wildcards from the matched route:

ts
// Route: '/users/:userId/posts/:postId'
// URL:   '/users/5/posts/42'
params.userId  // "5"
params.postId  // "42"

get(key) and set(key, value)

Read and write values in the request context. This is how middleware and handlers share data:

ts
// In middleware
context.set(Database, db)

// In handler
let db = get(Database)

See the Middleware guide for details on context keys.

Reading Form Data

For form submissions, the formData() middleware parses the request body and makes it available via the context:

ts
import { formData } from 'remix/form-data-middleware'

// In middleware setup
let router = createRouter({
  middleware: [formData()],
})

// In handler
handler({ get }) {
  let data = get(FormData)
  let name = data.get('name')       // string | File | null
  let email = data.get('email')     // string | File | null
  let tags = data.getAll('tags')    // (string | File)[]
}

FormData is the standard Web API FormData interface:

MethodDescription
data.get('key')Get a single value by name
data.getAll('key')Get all values for a name (for multi-select, checkboxes)
data.has('key')Check if a key exists
data.entries()Iterate over all key-value pairs

To convert FormData to a plain object:

ts
let values = Object.fromEntries(data) as Record<string, string>

Reading JSON Bodies

For API endpoints that receive JSON:

ts
handler({ request }) {
  let body = await request.json()
  // body is typed as `any` — validate it before use
}

Always validate incoming data

Whether it comes from a form or a JSON body, always validate incoming data on the server. Use data-schema for structured validation:

ts
import { object, string, parseSafe } from 'remix/data-schema'

let schema = object({ name: string(), email: string() })
let result = parseSafe(schema, body)
if (!result.success) {
  return Response.json({ errors: result.issues }, { status: 400 })
}

Reading Query Parameters

Query parameters are part of the URL (e.g. /search?q=remix&page=2). Access them through the url object:

ts
handler({ url }) {
  let query = url.searchParams.get('q')        // "remix"
  let page = url.searchParams.get('page')      // "2" (string!)
  let tags = url.searchParams.getAll('tag')     // ["js", "ts"]
  let hasFilter = url.searchParams.has('filter') // true or false
}

Query params are always strings

Like route params, query parameters are always strings. Convert them explicitly:

ts
let page = Number(url.searchParams.get('page')) || 1

Headers Utilities

Remix provides utilities for working with common headers from remix/headers:

ts
import {
  parseAccept,
  parseCacheControl,
  parseContentType,
} from 'remix/headers'

Content Negotiation

Content negotiation is the process of determining what format to respond with based on the client's Accept header:

ts
handler({ request }) {
  let accept = request.headers.get('Accept') ?? ''

  if (accept.includes('application/json')) {
    return Response.json({ books: [...] })
  }

  return createHtmlResponse('<h1>Books</h1>...')
}

Streaming Responses

For large responses, you can stream data using a ReadableStream:

ts
handler() {
  let stream = new ReadableStream({
    start(controller) {
      controller.enqueue(new TextEncoder().encode('Hello '))

      setTimeout(() => {
        controller.enqueue(new TextEncoder().encode('World!'))
        controller.close()
      }, 1000)
    },
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'text/plain' },
  })
}

This sends "Hello " immediately and "World!" one second later. The browser receives data as it arrives rather than waiting for the entire response.

Streaming is especially useful for:

  • Large HTML pages (see Streaming)
  • Server-Sent Events
  • File downloads
  • Real-time data feeds

Common Response Patterns

Success with Data

ts
return Response.json({ user: { id: 1, name: 'Alice' } })

Created

ts
return Response.json(
  { id: newBook.id, message: 'Book created' },
  { status: 201 },
)

No Content

ts
return new Response(null, { status: 204 })

Bad Request

ts
return Response.json(
  { error: 'Invalid email address' },
  { status: 400 },
)

Not Found

ts
return new Response('Page not found', { status: 404 })

Redirect After Mutation

ts
import { createRedirectResponse } from 'remix/response/redirect'

return createRedirectResponse(routes.books.index.href())

Setting Cookies

ts
let response = new Response('Logged in')
response.headers.set('Set-Cookie', 'token=abc; HttpOnly; Secure')
return response

In practice, you use the session middleware instead of setting cookies manually. See Sessions & Cookies.

Released under the MIT License.