From Zero to Remix: A Minimal Setup
Remix is an exciting evolution in React-based web development, but the quickest way to understand what it actually does is to strip away the scaffolding tools. The official npx create-remix@latest handles everything for you, but building an app from a blank folder reveals the essential pieces and how they connect.
This guide assumes you have some experience with Remix already. If you are brand new, create-remix is a much friendlier starting point.
Setting Up the Project
Start by creating a fresh project directory and installing the core packages:
npm install react react-dom
npm install --save-dev @remix-run/dev
Next, create a remix.config.js file. Even an empty configuration is required for the build to run:
module.exports = {}
The Build Process
Before writing any application code, it is helpful to see what Remix expects from your file structure. Add a build script to your package.json:
{
"scripts": {
"build": "remix build"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^1.6.5"
}
}
Running npm run build will fail, asking for the required entry files. The missing app/entry.client.jsx, app/entry.server.jsx, and app/root.jsx must each be created, letting the compiler tell you what you need to build up the application progressively.
After a successful build, the pre-build file structure includes your app files alongside a public directory for static assets:
.
├── app
│ ├── entry.client.jsx
│ ├── entry.server.jsx
│ └── root.jsx
├── package-lock.json
├── package.json
└── remix.config.js
Remix then generates a build directory with all the compiled server and client code:
.
├── app
│ ├── entry.client.jsx
│ ├── entry.server.jsx
│ └── root.jsx
├── build
│ ├── assets.json
│ └── index.js
├── package-lock.json
├── package.json
├── public
│ └── build
│ ├── _shared
│ │ └── chunk-DH6LPQ4Z.js
│ ├── entry.client-CY7AAJ4Q.js
│ ├── manifest-12E650A9.js
│ └── root-JHXSOSD4.js
└── remix.config.js
Note that Remix supports TypeScript out of the box, but using plain JavaScript requires the .jsx or .tsx extension for files containing JSX, as that is what esbuild expects.
Writing the Minimal App Code
With the build working, it is time to make the app actually render. The root file controls the entire HTML document that Remix outputs:
import * as React from 'react'
export default function App() {
const [count, setCount] = React.useState(0)
return (
<html>
<head>
<title>My First Remix App</title>
</head>
<body>
<p>This is a remix app. Hooray!</p>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
</body>
</html>
)
}
Control over the <html> element is a significant feature. It puts you in charge of the global markup structure rather than hiding it behind an abstraction.
The client entry point is responsible for hydrating the rendered HTML on the browser side:
import { RemixBrowser } from '@remix-run/react'
import { hydrateRoot } from 'react-dom/client'
hydrateRoot(document, <RemixBrowser />)
Similarly, the server entry point allows you to handle the rendering logic and manually craft the HTTP response. The returned Response object is a standard Web API response:
import ReactDOMServer from 'react-dom/server'
import { RemixServer } from '@remix-run/react'
export default function handleRequest(
request,
responseStatusCode,
responseHeaders,
remixContext,
) {
const markup = ReactDOMServer.renderToString(
<RemixServer context={remixContext} url={request.url} />,
)
responseHeaders.set('Content-Type', 'text/html')
return new Response(`<!DOCTYPE html>${markup}`, {
status: responseStatusCode,
headers: responseHeaders,
})
}
Manually calling renderToString and hydrate means you have direct control over the rendering lifecycle without needing to learn any custom APIs to modify this behavior. This low-level access is a core part of Remix's design philosophy.
To use the RemixBrowser and RemixServer components, install the React bindings package:
npm install @remix-run/react
Running the Server
Remix uses platform-specific adapters to convert between Web-standard Request/Response objects and the platform's native objects. Available adapters include:
@remix-run/node,@remix-run/express,@remix-run/servefor any Node-compatible deployment.@remix-run/deno,@remix-run/architect,@remix-run/vercel,@remix-run/netlify,@remix-run/cloudflare-workersfor serverless platforms.
Adapters are small (often a few hundred lines of code), and you can write your own. For this demo, @remix-run/serve provides a simple Express server. Update the dev script in your package.json to run it:
{
"scripts": {
"build": "remix build",
"dev": "remix dev"
},
"dependencies": {
"@remix-run/react": "^1.6.5",
"@remix-run/serve": "^1.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^1.6.5"
}
}
When npm run dev executes, two processes start:
Remix App Server started at http://localhost:3000, which runs your compiled build.- The build process, watching for file changes and recompiling.
The dev server also opens a websocket to the browser for live reloads when you edit server-side code. Full-page refreshes are actually a sensible default for a framework where much of the logic runs on the server anyway.
Adding Client-Side JavaScript
A basic Remix page renders perfectly without any JavaScript loaded. But clicking a button with an event handler will do nothing until you explicitly opt in. Open app/root.jsx and import the Scripts component to include the necessary script tags:
import * as React from 'react'
import { Scripts } from '@remix-run/react'
export default function App() {
const [count, setCount] = React.useState(0)
return (
<html>
<head>
<title>My First Remix App</title>
</head>
<body>
<p>This is a remix app. Hooray!</p>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<Scripts />
</body>
</html>
)
}
Remix also injects resource preloading based on what the server renders, avoiding a waterfall of network requests — all resources start loading in parallel almost immediately.
Production Mode
To simulate a production environment locally, run a standard build first:
npm run build
Building Remix app in production mode...
Built in 281ms
Then add a start script to run the production server:
{
"scripts": {
"build": "remix build",
"dev": "remix dev",
"start": "remix-serve ./build"
},
"dependencies": {
"@remix-run/react": "^1.6.5",
"@remix-run/serve": "^1.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^1.6.5"
}
}
For proper behavior, ensure the application runs with NODE_ENV=production. A tool like cross-env handles setting this environment variable across platforms:
{
"scripts": {
"build": "remix build",
"dev": "remix dev",
"start": "cross-env NODE_ENV=production remix-serve ./build"
},
"dependencies": {
"@remix-run/react": "^1.6.5",
"@remix-run/serve": "^1.6.5",
"cross-env": "^7.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^1.6.5"
}
}
Running the production server serves your compiled app as it would in a real deployment:
npm start
Remix App Server started at http://localhost:3000 (http://192.168.115.103:3000)
With those pieces in place, the app works in development and production, leaving you with a clear mental model of how the framework compiles a React application, renders it on the server, and hydrates it on the client. The full code for this walkthrough is available on GitHub: kentcdodds/super-simple-start-to-remix.



