Core ConceptsFrontend Application Shell

Frontend Application Shell

How the Remix frontend is scaffolded today, including root layout, routes, styling, error handling, and packaging.

Overview

The current frontend is a thin Remix application shell with two implemented routes, a shared starter-style welcome screen, global Tailwind-based styling, and basic packaging for local development and containers. It establishes the document structure, asset pipeline, and runtime boundaries for the web app, but it does not yet contain product-specific UI, authentication, or backend-driven data flows.

App purpose and current scope

Today, the frontend serves as a scaffold rather than a feature-complete application. Both implemented routes render the same shared Welcome component, and the visible UI is still the default starter-style screen.

This page reflects the current implementation in apps/web. The shell includes routing, layout, styling, error handling, local scripts, and Docker packaging. It does not yet include app-specific screens, API calls, session handling, or workflow UI.

The implemented files that define the shell are:

  • apps/web/app/root.tsx
  • apps/web/app/app.css
  • apps/web/app/routes/_index.tsx
  • apps/web/app/routes/home.tsx
  • apps/web/app/welcome/welcome.tsx
  • apps/web/vite.config.ts
  • apps/web/package.json
  • apps/web/Dockerfile
  • apps/web/VERSION

Route map

Two routes are currently implemented, and both render the same shared component.

/

The index route is defined in apps/web/app/routes/_index.tsx. It exports Remix metadata and returns Welcome.

import { Welcome } from "../welcome/welcome";

export default function Home() {
  return <Welcome />;
}

/home

The /home route is defined in apps/web/app/routes/home.tsx. It is effectively identical to the index route and also renders Welcome.

import { Welcome } from "../welcome/welcome";

export default function Home() {
  return <Welcome />;
}

Because both routes render the same component, there is no functional distinction between / and /home yet. Any product-specific route structure still needs to be added.

Root layout

apps/web/app/root.tsx defines the application document shell. It imports global CSS, registers font-related links, provides the HTML document structure, and renders the route outlet.

Global style import and linked assets

The root file imports the shared stylesheet directly and exposes font-related link tags through Remix links().

import "./app.css";

This is the only font configuration currently present. The shell preconnects to Google Fonts and loads Inter for the global sans stack.

Document structure and Remix primitives

The Layout export defines the outer document and includes the standard Remix primitives used by the app shell.

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

A few points matter here:

  • Meta renders route-level metadata such as the title and description exported by route modules.
  • Links injects the stylesheet and font links registered through Remix.
  • Outlet is the only content rendered by the root app component, so the shell currently applies no shared navigation, providers, or app frame around routes.
  • ScrollRestoration and Scripts are included at the document level, which is the expected baseline for Remix apps.

Error handling

The root file also defines the current error boundary. It distinguishes route error responses from runtime errors and exposes more detail during development.

Error boundary logic

The implementation starts with fallback values, then refines the message based on the error type.

export function ErrorBoundary() {
  const error = useRouteError();
  let message = "Oops!";
  let details = "An unexpected error occurred.";
  let stack: string | undefined;

  if (isRouteErrorResponse(error)) {
    message = error.status === 404 ? "404" : "Error";
    details =
      error.status === 404
        ? "The requested page could not be found."
        : error.statusText || details;
  } else if (import.meta.env.DEV && error && error instanceof Error) {
    details = error.message;
    stack = error.stack;
  }

Current behavior

The boundary currently behaves as follows:

  • 404 route errors render 404 with the message The requested page could not be found.
  • Other route error responses render Error and use error.statusText when present
  • Development runtime errors render the thrown error message and stack trace
  • Fallback cases render Oops! and An unexpected error occurred.

This is a practical starter boundary. It is useful for local development, but it does not yet include branded error pages, structured logging, or recovery flows.

Styling pipeline

The styling stack is intentionally small. Global styles live in app.css, and Vite is configured with only the plugins required for Tailwind, Remix, and TypeScript path resolution.

Global CSS in app.css

The stylesheet imports Tailwind, defines the sans font theme variable, and sets the document background for light and dark mode.

@import "tailwindcss";

@theme {
    --font-sans:
        "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
        "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}

html,
body {
    background-color: var(--color-white);

    @media (prefers-color-scheme: dark) {
        background-color: var(--color-gray-950);
        color-scheme: dark;
    }
}

From this file alone, the shell currently guarantees:

  • Tailwind CSS is loaded globally
  • Inter is first in the sans font stack
  • html and body default to a white background
  • dark mode switches the background to var(--color-gray-950) and enables color-scheme: dark

Vite and Remix configuration

The build configuration is defined in apps/web/vite.config.ts.

import { vitePlugin as remix } from "@remix-run/dev";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [tailwindcss(), remix(), tsconfigPaths()],
});

No additional server settings, proxy behavior, environment-specific overrides, or custom build options are configured here. The shell uses the default behavior provided by these plugins.

Welcome and home UI

The visible UI for both routes lives in apps/web/app/welcome/welcome.tsx. It is still a placeholder screen intended to confirm that the shell is running.

What the current UI does

The component renders a centered layout, a logo that switches by color scheme, and a small navigation card with external links.

<main className="flex items-center justify-center pt-16 pb-4">
  <div className="flex-1 flex flex-col items-center gap-16 min-h-0">
    <header className="flex flex-col items-center gap-9">
      <div className="w-[500px] max-w-[100vw] p-4">
        <img src={logoLight} alt="React Router" className="block w-full dark:hidden" />
        <img src={logoDark} alt="React Router" className="hidden w-full dark:block" />
      </div>
    </header>

What the current UI does not do

At this stage, the welcome screen is not connected to any application behavior. The current implementation has:

  • no API integration
  • no backend data fetches
  • no auth or session UI
  • no domain-specific navigation
  • no business workflow screens

The two external links in the resources list open in a new tab, and the screen remains a starter-style landing page rather than an application homepage.

Local run and build

apps/web/package.json defines the complete local script surface for the frontend shell.

Run these commands from apps/web so package.json and the local build output resolve as expected.

"scripts": {
  "build": "remix vite:build",
  "dev": "remix vite:dev",
  "start": "remix-serve ./build/server/index.js",
  "typecheck": "tsc"
}

These commands map directly to the current shell lifecycle:

  • npm run dev starts the Remix Vite development server
  • npm run build creates the production build
  • npm run start serves the built app from ./build/server/index.js
  • npm run typecheck runs TypeScript without a separate lint or test command in this package

Other relevant package facts currently visible in package.json:

  • package name is @repo/web
  • module type is module

Container packaging

The frontend can also be built and run as a container. The Dockerfile uses a two-stage Node 20 Alpine build and serves the production Remix bundle on port 3000.

Docker build and runtime flow

FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install --include=dev
COPY . .
# tsconfig.json extends ../../tsconfig.base.json — place it at the expected path
COPY tsconfig.base.json /tsconfig.base.json
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY --from=build /app/build ./build
EXPOSE 3000
CMD ["npm", "run", "start"]

A few implementation details matter:

  • the build stage installs development dependencies and runs npm run build
  • the runtime stage installs production dependencies only
  • the built ./build output is copied into the final image
  • the container exposes port 3000
  • the startup command is npm run start, which resolves to remix-serve ./build/server/index.js
  • apps/web/VERSION is currently 0.1.0

Current limitations

The frontend shell does not yet include authentication, backend data integration, or business workflow UI. It is a starter scaffold with routing, layout, styling, error handling, and packaging only.

That constraint is important when planning the next layer of frontend work. New pages, loaders, actions, shared app chrome, and API-facing components still need to be introduced on top of this shell.