Building a Full-Stack TypeScript App with Remix and Express
Want SSR, WebSocket support, and a solid React setup without all the magic and headaches? This post breaks down how I wired up a full-stack TypeScript app using Remix and Express, with end to end type security, in this setup, you get full control over both the frontend and backend—no black boxes, no vendor lock-in. Just fast builds, clean architecture, and everything TypeScript-typed from top to bottom.
Note: This exact architecture powers What to Eat Near Me, a production site that helps users find local restaurants.
You can find the complete source code for this setup in the GitHub repository.
Why Remix + Express?
Note: The original Remix template doesn't include a TypeScript-ready Express server.
Combining Remix and Express provides a flexible, fully controlled environment. Remix handles server-side rendering (SSR), file-based routing, and advanced React patterns, while Express covers all custom API needs, from simple REST endpoints to stateful long-lived connections like WebSocket.
Key benefits:
- Stateful Server: Perfect for use cases requiring persistent or long-lived connections (e.g., WebSocket rooms, session-based logic).
- Full Control: You have your own container with Express—ideal for running on a personal VPS.
- Security: Everything runs under one roof in TypeScript. If you want to lock down sensitive logic, you're not depending on a Back-end as a Service, but hosting your own server.
- End-to-End Type Safety: By sharing types between the Remix app and Express code, you ensure consistent data structures.
Stack Overview
- Remix for app routing and SSR
- Express for the backend server
- Vite for lightning-fast builds and HMR
- TypeScript throughout the stack
- Drizzle ORM for database operations
- TailwindCSS for styling
- Docker for containerized deployments
Folder Structure
Below is a condensed folder structure for readability:
.
├─ .vscode/
├─ app/
│ ├─ components/
│ ├─ context/
│ ├─ hooks/
│ ├─ routes/
│ ├─ services/
│ └─ types/
├─ build/
│ ├─ client/
│ ├─ node/
│ │ └─ server.js
│ └─ server/
│ └─ index.js
├─ common/
│ ├─ auth/
│ ├─ db/
│ ├─ errors/
│ └─ type/
├─ drizzle/
├─ public/
├─ src/
│ ├─ api/
│ ├─ services/
│ └─ utils/
├─ Dockerfile
├─ package.json
├─ server.ts
├─ tsconfig.json
├─ tsconfig.server.json
└─ vite.config.ts
Key Highlights:
app/
holds Remix frontend code—routes, components, etc.src/
is where server-specific code lives (e.g., Express routes, utility modules).common/
is for shared logic (database schemas, errors, universal types) that both server and Remix builds access.build/
is the output folder after compiling. It splits intoclient/
(Vite for Remix),node/
(TypeScript for server), andserver/
(Remix SSR build).
Below is a snippet of server.ts
, showing how we integrate Remix and Express:
import 'dotenv/config';
import { createRequestHandler } from "@remix-run/express";
import type { ViteDevServer } from 'vite';
import type { ServerBuild } from "@remix-run/node";
import apiRouter from './src/api/index.js';
//.. and others
let viteDevServer: ViteDevServer | undefined;
const initViteServer = async (): Promise<ViteDevServer | undefined> => {
if (process.env.NODE_ENV === "production") {
return undefined;
}
const vite = await import("vite");
return vite.createServer({
server: { middlewareMode: true },
});
};
const initServer = async () => {
viteDevServer = await initViteServer();
const app = express();
//... Middlewares
// Static assets
app.use(express.static("build/client", { maxAge: "1h" }));
if (viteDevServer) {
app.use(viteDevServer.middlewares);
} else {
app.use(
"/assets",
express.static("build/client/assets", { immutable: true, maxAge: "1y" })
);
}
// API routes must come before any static or Remix middleware
app.use('/api', (req, _, next) => {
// Log API requests
console.log(`API Request: ${req.method} ${req.url}`);
next();
}, apiRouter);
// In production, the server build is in build/server/index.js
const importPath = process.env.NODE_ENV === "production" ? "../server/index.js" : "./index.js";
// Remix handler comes last
const remixHandler = createRequestHandler({
build: viteDevServer
? () => {
if (!viteDevServer) throw new Error('Vite server not initialized');
return viteDevServer.ssrLoadModule("virtual:remix/server-build") as Promise<ServerBuild>;
}
: await import(importPath) as unknown as ServerBuild,
});
// All other routes go to Remix
app.all("*", remixHandler);
const port = Number(process.env.PORT) || 3000;
app.listen(port, '0.0.0.0', () =>
console.log(`Express server listening at http://0.0.0.0:${port}`)
);
};
initServer().catch(console.error);
This looks oddly complicated, well, let's ignore the Vite development server part for now, let's solely look at the import path in production
in the remix handler method, if NODE_ENV
is production
, our import path for the remix handler module would be in ../server/index.js
This is becuase we built the server into the the following structure.
├─ build/
│ ├─ client/
│ ├─ node/
│ │ └─ server.js
│ └─ server/
│ └─ index.js
Build Setup
We use two separate pipelines:
- Frontend:
vite
builds the Remix client assets tobuild/client
. - Backend:
tsc -p tsconfig.server.json
compiles the server tobuild/node
.
All scripts are orchestrated via package.json
:
{
"scripts": {
"dev": "cross-env NODE_ENV=development tsx ./server.ts",
"build": "npm run build:remix && npm run build:server",
"build:remix": "remix vite:build",
"build:server": "tsc -p tsconfig.server.json",
"start": "cross-env NODE_ENV=production node ./build/node/server.js"
}
}
TypeScript Configuration
We use separate tsconfig
files:
tsconfig.json
– shared between Vite and Remix for the frontendtsconfig.server.json
– focuses on server-only code
Note: Both your server and remix code needs to have the same module resolution, Otherwise your common module won't work
{
"compilerOptions": {
//...
"module": "ESNext",
"moduleResolution": "node",
//...
}
}
Dealing with a Shared "Common" Module
One of the trickiest parts of this setup is managing shared code between two distinct build pipelines: Vite (used by Remix) and tsc
(used by the server). The shared common/
module is a natural place to put code like database logic, utility types, and validation schemas—but sharing this module requires care.
Note: Remix + Vite handle their own bundling, while your Express server relies on native Node.js module resolution. You cannot merge the two into a single output—they are fundamentally built for different runtimes.
Here are key caveats to keep in mind:
Double Compilation
Shared code in common/
gets compiled twice:
- Once by Vite when Remix bundles the frontend.
- Once by
tsc
when compiling the server.
This is expected and unavoidable. Vite consumes the source .ts
files directly, whereas Node.js needs .js
output to execute.
Path Resolution
Vite and tsc
may resolve paths differently. You can use paths
in tsconfig.json
to help with aliases, but in production, you'll need to ensure that relative imports in server.ts
resolve to .js
files, or Node.js will throw errors.
Mutual References
Avoid importing Remix app code (e.g. app/
) into your server (e.g. src/
), or vice versa. Here's why:
- Remix code isn't Node-native: It’s compiled for the browser or SSR using a bundler. Attempting to load Remix code in Express will either fail or pull in unnecessary and incompatible browser logic.
- Server code isn't bundler-friendly: Express modules compiled with
tsc
follow Node's module resolution. They often rely on filesystem structure, file extensions (e.g..js
), and runtime assumptions that don't align with how Vite bundles code.
Trying to cross these boundaries leads to broken imports and inconsistent runtime behavior.
What Works
Shared logic should live in common/
, written with both environments in mind:
- Use only platform-agnostic code: no DOM APIs or Node-only modules.
- Use
globalThis
sparingly for truly singleton values like database connections. - Stick to relative imports and avoid deep aliasing in production builds.
Example: For a shared Postgres connection:
// common/db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
const globalForDb = globalThis as unknown as { db: ReturnType<typeof drizzle> };
export const db =
globalForDb.db ?? (globalForDb.db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL })));
This works safely in both server builds and Remix environments where needed.
In short, treat common/
as a neutral zone for logic, types, and helpers—but draw a hard line between your Remix app and your Node server. Let each tool do what it does best, and share only what makes sense.
The problem with remix-auth
remix-auth
does not recognize the express request, so we need to do some special re-mapping for it
To unify authentication across both Remix and Express APIs, you can reuse the same authenticator from remix-auth in your custom Express middleware. Here's how:
You can find the file here
import { authenticator } from "../../common/auth/auth.server.js";
export async function getAuthUser(req: import("express").Request) {
const origin = `${req.protocol}://${req.get("host")}`;
const url = new URL(req.originalUrl, origin);
const headers = new Headers();
// Copy over all headers from Express
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
headers.set(key, value.join(","));
} else {
headers.set(key, value);
}
}
const remixRequest = new Request(url.href, {
method: req.method,
headers,
body: ["GET", "HEAD"].includes(req.method) ? undefined : req.body,
});
return authenticator.isAuthenticated(remixRequest);
}
Why Run Your Own Server?
At some point, I realized that serverless isn't the cost-saving magic it's often marketed as—especially for long-lived or stateful workloads. Next.js is a powerful framework, but its hydration quirks and hard edges drove me crazy. I still remember when ChatGPT first launched: Next.js handled SSE just fine locally, but once deployed to edge functions, everything broke. It took quite a while for the ecosystem to catch up.
Serverless JavaScript, in particular, feels like a myth. You're billed by the second for function runtime—how does that make sense when JavaScript can handle thousands of concurrent requests with a single long-lived process? You're paying for ephemeral runtimes that fundamentally don't align with how efficient a Node.js server can be.
Next.js also feels like a black box. Builds are slow, and debugging issues deep within the framework can be frustrating. By contrast, Remix + Express + Coolify has been a far better experience for me. It's transparent, fast to build, and easy to scale manually.
And sure, you might ask: “But what about scalability?” To that, I say—check out Serverless Horrors. If my app ever blows up, I'd rather have my VPS crash and spend a few hours (and a few hundred dollars) spinning up new Express instances, than wake up to a surprise $5,000 AWS or Vercel bill.
Final Thoughts
The hardest part is getting the common/ module and shared logic to work nicely across both build systems. Once you’ve got that figured out, Remix and Vite basically take care of the rest.
Everything just clicks: SSR works out of the box, builds are fast, and you get a clean separation between frontend and backend without feeling like you're fighting the framework. Express gives you full control, and Remix lets you write React code the way it’s meant to be—no weird hydration bugs or framework magic that gets in the way.
Honestly, the combo of Remix + Express + Vite feels like the sweet spot: fast dev experience, predictable deployment, and real control. If you’ve ever felt like modern web stacks are too opinionated or too tied to big platforms, give this setup a try. It’s been a breath of fresh air for me.