Skip to content
All essays
WebJanuary 15, 202612 min

Remix Framework: Getting Started with Modern Full-Stack React

A comprehensive introduction to Remix framework, covering setup, project structure, core concepts, and building your first application

Ü
Ümit Uz
Mobile & Full Stack Developer

Remix is a full-stack web framework that lets you focus on the user interface. It combines the speed of server-side rendering with the interactivity of client-side JavaScript. This guide will get you up and running with Remix.

What is Remix?

Remix is built on:

  • Web Standards - Uses the Web Fetch API, Request, and Response objects
  • React - Modern React for building UIs
  • Nested Routing - Route-based architecture for data loading
  • Server-Side Rendering - Fast initial page loads and SEO
  • Progressive Enhancement - Works without JavaScript

Installation

Creating a New Project

bash
# Create a new Remix project
npx create-remix@latest

# Or with specific template
npx create-remix@latest my-app --template remix-run/remix/templates/remix

# Navigate and install
cd my-app
npm install

# Start development server
npm run dev

Project Structure

my-remix-app/
├── app/
│   ├── routes/          # Route files
│   │   ├── _index.tsx   # Home route
│   │   └── about.tsx    # About route
│   ├── root.tsx         # Root component
│   ├── entry.client.tsx # Browser entry
│   ├── entry.server.tsx # Server entry
│   └── styles.css       # Global styles
├── public/              # Static assets
├── package.json
├── remix.config.js      # Remix configuration
└── tsconfig.json        # TypeScript config

Core Concepts

Routes

Routes are the heart of Remix. Each file in app/routes becomes a route:

tsx
// app/routes/_index.tsx
export default function Index() {
  return (
    <div>
      <h1>Welcome to Remix!</h1>
    </div>
  );
}

Route File Naming

  • _index.tsx - Index route (/`)
  • about.tsx - /about
  • blog._index.tsx - /blog
  • blog.$slug.tsx - /blog/:slug
  • files.$.tsx - Wildcard route (/files/*)

Nested Routing

Remix uses nested routing for layout and data:

tsx
// app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from '@remix-run/react';

export default function App() {
  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <header>
          <nav>
            <a href="/">Home</a>
            <a href="/about">About</a>
            <a href="/blog">Blog</a>
          </nav>
        </header>

        <main>
          <Outlet /> {/* Child routes render here */}
        </main>

        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}
tsx
// app/routes/dashboard.tsx
import { Outlet, Link } from '@remix-run/react';

export default function Dashboard() {
  return (
    <div className="dashboard">
      <aside>
        <nav>
          <Link to="/dashboard/overview">Overview</Link>
          <Link to="/dashboard/settings">Settings</Link>
          <Link to="/dashboard/analytics">Analytics</Link>
        </nav>
      </aside>

      <div className="dashboard-content">
        <h1>Dashboard</h1>
        <Outlet /> {/* Nested routes */}
      </div>
    </div>
  );
}
tsx
// app/routes/dashboard.overview.tsx
export default function Overview() {
  return (
    <div>
      <h2>Overview</h2>
      <p>Welcome to your dashboard overview.</p>
    </div>
  );
}

Loaders

Loaders run on the server to provide data to your route:

tsx
// app/routes/posts.tsx
import { useLoaderData, Link } from '@remix-run/react';
import { json } from '@remix-run/node';

// Loader runs on the server
export const loader = async () => {
  const posts = [
    { id: 1, title: 'First Post', slug: 'first-post' },
    { id: 2, title: 'Second Post', slug: 'second-post' },
  ];

  return json(posts);
};

export default function Posts() {
  const posts = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={post.slug}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

Fetching Data from APIs

tsx
import { json } from '@remix-run/node';

export const loader = async () => {
  const response = await fetch('https://api.example.com/posts');

  if (!response.ok) {
    throw new Response('Failed to fetch posts', { status: 500 });
  }

  const posts = await response.json();
  return json(posts);
};

Accessing Request Data

tsx
import { json } from '@remix-run/node';

export const loader = async ({ request }: LoaderArgs) => {
  const url = new URL(request.url);
  const searchTerm = url.searchParams.get('q');

  const posts = await getPosts(searchTerm);
  return json(posts);
};

Using Environment Variables

tsx
const API_URL = process.env.API_URL || 'http://localhost:3000';

export const loader = async () => {
  const response = await fetch(`${API_URL}/posts`);
  const posts = await response.json();
  return json(posts);
};

Actions

Actions handle form submissions:

tsx
// app/routes/contact.tsx
import { Form, useActionData } from '@remix-run/react';
import { json, redirect } from '@remix-run/node';
import { action } from '.';

export const action = async ({ request }: ActionArgs) => {
  const formData = await request.formData();
  const name = formData.get('name');
  const email = formData.get('email');
  const message = formData.get('message');

  // Validate
  if (!name || !email || !message) {
    return json({ error: 'All fields are required' }, { status: 400 });
  }

  // Save to database
  await saveContact({ name, email, message });

  // Redirect
  return redirect('/contact/success');
};

export default function Contact() {
  const actionData = useActionData<typeof action>();

  return (
    <div>
      <h1>Contact Us</h1>

      {actionData?.error && (
        <p className="error">{actionData.error}</p>
      )}

      <Form method="post">
        <div>
          <label htmlFor="name">Name</label>
          <input id="name" name="name" type="text" required />
        </div>

        <div>
          <label htmlFor="email">Email</label>
          <input id="email" name="email" type="email" required />
        </div>

        <div>
          <label htmlFor="message">Message</label>
          <textarea id="message" name="message" required />
        </div>

        <button type="submit">Send</button>
      </Form>
    </div>
  );
}

Error Handling

Catching Errors

tsx
// app/routes/posts.$postId.tsx
import { useLoaderData, useRouteError } from '@remix-run/react';

export const loader = async ({ params }: LoaderArgs) => {
  const post = await getPost(params.postId);

  if (!post) {
    throw new Response('Not Found', { status: 404 });
  }

  return json(post);
};

export default function Post() {
  const post = useLoaderData<typeof loader>();
  return <div>{post.title}</div>;
}

export function ErrorBoundary() {
  const error = useRouteError();

  if (error instanceof Response && error.status === 404) {
    return <div>Post not found</div>;
  }

  return <div>Something went wrong</div>;
}

Styling

Using CSS

tsx
// app/routes/about.tsx
import styles from '~/styles/about.css?url';

export function links() {
  return [{ rel: 'stylesheet', href: styles }];
}

export default function About() {
  return (
    <div className="about">
      <h1>About Us</h1>
      <p>Learn more about our company.</p>
    </div>
  );
}

Using Tailwind CSS

tsx
export default function Index() {
  return (
    <div className="container mx-auto px-4">
      <h1 className="text-4xl font-bold">Welcome</h1>
    </div>
  );
}

Client-Side Interactivity

Using useEffect

tsx
import { useEffect } from 'react';

export default function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const interval = setInterval(() => {
      setTime(new Date());
    }, 1000);

    return () => clearInterval(interval);
  }, []);

  return <div>{time.toLocaleTimeString()}</div>;
}

Client Components

tsx
import { ClientOnly } from '@remix-run/react';

export default function InteractiveMap() {
  return (
    <ClientOnly fallback={<p>Loading map...</p>}>
      {() => <MapComponent />}
    </ClientOnly>
  );
}

Deployment

Building for Production

bash
# Build the app
npm run build

# Start production server
npm start

Environment Variables

Create a .env file:

DATABASE_URL=postgresql://...
SESSION_SECRET=your-secret-key
API_KEY=your-api-key

Access in code:

tsx
const DATABASE_URL = process.env.DATABASE_URL;

Best Practices

  1. 1Use Nested Routes - Leverage nested routing for layouts
  2. 2Server-Side First - Load data on the server when possible
  3. 3Error Boundaries - Always include error boundaries
  4. 4Progressive Enhancement - Ensure forms work without JS
  5. 5TypeScript - Use TypeScript for better DX
  6. 6Optimize Images - Use Remix's image optimization

Conclusion

Remix provides a modern, standards-based approach to building web applications. By combining the speed of server-side rendering with the interactivity of client-side JavaScript, it offers the best of both worlds.

Start with the basics: understand routes, loaders, and actions. Then explore nested routing, error handling, and deployment strategies. With Remix, you get fast initial loads, excellent SEO, and great user experiences.

Enjoyed this essay? Share it.

Related essays

Next essay
Mastering Remix Routing: Nested Routes and Layouts