Skip to main content
Remix is built entirely on web standards. This means Remix packages work seamlessly across any JavaScript runtime that implements standard web APIs - no runtime-specific code required.

Why Web Standards?

Building on web standards provides several critical advantages:

Universal Compatibility

Code written with web standards works in browsers, Node.js, Bun, Deno, and Cloudflare Workers without modification.

Future-Proof

Web standards evolve slowly and maintain backward compatibility. Your code won’t break when runtimes update.

Reduced Context Switching

Same APIs everywhere means less to learn and remember. Knowledge transfers across the stack.

AI-Friendly

LLMs are trained on web standards. Using them makes your code more understandable to AI tools.

Core Web APIs Used by Remix

Remix leverages server-side web APIs wherever available:

Fetch API

The Fetch API is the foundation of Remix’s HTTP handling.
import { createRouter } from 'remix/fetch-router'
import { route } from 'remix/fetch-router/routes'

let routes = route({
  api: '/api/users/:id',
})

let router = createRouter()

// Actions receive a standard Request object
router.get(routes.api, async ({ request, params }) => {
  // Standard Request properties
  console.log(request.method)    // "GET"
  console.log(request.url)       // "https://example.com/api/users/123"
  console.log(request.headers.get('Accept'))
  
  // Return a standard Response
  return new Response(
    JSON.stringify({ id: params.id, name: 'Alice' }),
    {
      status: 200,
      headers: {
        'Content-Type': 'application/json',
      },
    },
  )
})
Remix routers work with the standard fetch() function - no special test harness required. Just call await router.fetch(url) in your tests.

Web Streams API

Remix uses Web Streams instead of Node.js streams:
// ✅ Web Streams API (works everywhere)
import { parseMultipartRequest } from 'remix/multipart-parser'

async function handleUpload(request: Request) {
  // Request.body is a ReadableStream
  for await (let part of parseMultipartRequest(request)) {
    if (part.isFile) {
      // Stream file content without buffering
      let stream = part.stream() // Returns ReadableStream
      await saveToStorage(stream)
    }
  }
}

// ❌ Node.js streams (runtime-specific)
// import { Readable } from 'node:stream'
import { openLazyFile } from 'remix/fs'

// Stream large files efficiently
let file = openLazyFile('./video.mp4')

return new Response(file.stream(), {
  headers: {
    'Content-Type': 'video/mp4',
    'Content-Length': String(file.size),
  },
})

Uint8Array Instead of Buffer

Remix uses standard Uint8Array instead of Node.js Buffer:
import { parseMultipartRequest } from 'remix/multipart-parser'

async function handleUpload(request: Request) {
  for await (let part of parseMultipartRequest(request)) {
    if (part.isFile) {
      // part.bytes is a Uint8Array (works everywhere)
      let bytes: Uint8Array = part.bytes
      
      // Convert to ArrayBuffer if needed
      let buffer: ArrayBuffer = part.arrayBuffer
      
      console.log(`Received ${bytes.byteLength} bytes`)
    }
  }
}
Uint8Array is a standard JavaScript typed array that works in all environments. Node.js Buffer is a Node-specific subclass of Uint8Array.

Web Crypto API

Use Web Crypto instead of node:crypto:
// ✅ Web Crypto API (works everywhere)
let data = new TextEncoder().encode('Hello, world!')
let hashBuffer = await crypto.subtle.digest('SHA-256', data)
let hashArray = new Uint8Array(hashBuffer)
let hashHex = Array.from(hashArray)
  .map(b => b.toString(16).padStart(2, '0'))
  .join('')

// Generate random values
let randomBytes = crypto.getRandomValues(new Uint8Array(16))

// ❌ Node.js crypto (runtime-specific)
// import { createHash, randomBytes } from 'node:crypto'

Blob and File APIs

Remix uses standard Blob and File objects:
// Standard File API works everywhere
let file = new File(
  ['Hello, world!'],
  'hello.txt',
  { type: 'text/plain' },
)

console.log(file.name)  // "hello.txt"
console.log(file.type)  // "text/plain"
console.log(file.size)  // 13

let text = await file.text()
let buffer = await file.arrayBuffer()
let stream = file.stream()

URL and URLSearchParams

Remix uses standard URL and URLSearchParams:
import { createRouter } from 'remix/fetch-router'

let router = createRouter()

router.get('/search', ({ url }) => {
  // url is a standard URL object
  let query = url.searchParams.get('q')
  let page = parseInt(url.searchParams.get('page') || '1')
  
  return Response.json({
    query,
    page,
    results: searchDatabase(query, page),
  })
})

FormData

Standard FormData for form handling:
import { createRouter } from 'remix/fetch-router'
import { formData } from 'remix/form-data-middleware'

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

router.post('/contact', ({ get }) => {
  // FormData is parsed by middleware
  let form = get(FormData)
  
  let name = form.get('name') as string
  let email = form.get('email') as string
  let message = form.get('message') as string
  
  // File uploads are File objects
  let avatar = form.get('avatar') as File
  
  return Response.json({ success: true })
})

Augmenting Standards Unobtrusively

When web standards are incomplete, Remix augments them carefully:

LazyFile Extends File

import { type LazyContent, LazyFile } from 'remix/lazy-file'

// LazyFile implements the File interface
// but accepts a LazyContent source
let content: LazyContent = {
  byteLength: 100000,
  stream(start, end) {
    return new ReadableStream({/* ... */})
  },
}

let lazyFile = new LazyFile(content, 'data.bin', {
  type: 'application/octet-stream',
})

// Still a valid File - can convert to standard File
let standardFile = await lazyFile.toFile()
.toFile() and .toBlob() read the entire file into memory. Only use these for non-streaming APIs like FormData. Always prefer .stream() when possible.

Route Pattern Matching

import { createPattern } from 'remix/route-pattern'

// Route patterns follow URL Pattern API proposals
let pattern = createPattern('/users/:id')

let match = pattern.match('/users/123')
if (match) {
  console.log(match.params.id) // "123" (typed!)
}

let url = pattern.generate({ id: '456' })
console.log(url) // "/users/456"

Benefits in Practice

Here’s the same file upload functionality implemented across different runtimes using identical Remix code:
import * as http from 'node:http'
import { parseMultipartRequest } from 'remix/multipart-parser/node'

let server = http.createServer(async (req, res) => {
  for await (let part of parseMultipartRequest(req)) {
    if (part.isFile) {
      console.log(`File: ${part.filename} (${part.size} bytes)`)
    }
  }
})

server.listen(3000)
Notice how the core parsing logic is identical across all runtimes. Only the server setup differs based on each runtime’s API.

Testing With Standards

Web standards make testing straightforward:
import * as assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { createRouter } from 'remix/fetch-router'

describe('API routes', () => {
  it('returns user data', async () => {
    let router = createRouter()
    
    router.get('/api/users/:id', ({ params }) => {
      return Response.json({ id: params.id, name: 'Alice' })
    })
    
    // Use standard fetch to test
    let response = await router.fetch('https://example.com/api/users/123')
    
    assert.equal(response.status, 200)
    assert.equal(response.headers.get('Content-Type'), 'application/json')
    
    let data = await response.json()
    assert.equal(data.id, '123')
    assert.equal(data.name, 'Alice')
  })
})
No mocking required. router.fetch() accepts standard Request objects and returns standard Response objects.

Next Steps

Architecture

Learn about Remix’s composable package architecture

Runtime Agnostic

See how Remix achieves true runtime portability

Composability

Explore patterns for composing packages

Fetch Router

Build routers with the Fetch API

Build docs developers (and LLMs) love