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:

  1. Stateful Server: Perfect for use cases requiring persistent or long-lived connections (e.g., WebSocket rooms, session-based logic).
  2. Full Control: You have your own container with Express—ideal for running on a personal VPS.
  3. 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.
  4. 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 into client/ (Vite for Remix), node/ (TypeScript for server), and server/ (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 to build/client.
  • Backend: tsc -p tsconfig.server.json compiles the server to build/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:

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.