Want to experience

This article is a quick start experience and translation based on Remix’s official website. So the content is the same as the official website.

  • Create a project
  • Your first route
  • Loading Data
  • A little bit of refactoring
  • Pull data from the data source
  • Dynamic routing parameters
  • Create a Blog post
  • Root routing
  • Submit the form

Create a project

Initialize a new Remix project

NPX create-remix@latest # Select Remix App Server CD [your own project directory] NPM run devCopy the code

Note that Remix App Server is selected here

After running NPX create-remix@latest, select Remix App Server, select TypeScript as the development language, and run NPM Install. You can then wait for the dependencies to download. After downloading the dependency packages, open your browser to http://localhost:3000 and you will see the following interface:

Your first route

We will add a route /posts. To do this, we need to create a Link to jump to the route.

First of all, open upapp/root.tsxTo find<Link to="/">Home</Link>Next to it, create a new link to/postsA link to the

Add a link to jump to the article

<li>
  <Link to="/posts">Posts</Link>
</li>
Copy the code

At this point, if we click on the link in the page, we will see a 404 page. Because we haven’t added a route yet. Let’s add this route:

Create a new file: app/routes/posts/index. The TSX

Routing files are placed under routes. A file represents a route. We could also create a posts.jsx file directly, but we might create a post-detail.tsx route in the future if we have a post detail.tsx route, or if we have nested routes, it’s not very easy to manage. So we can create an index.tsx in the Posts folder as a route entry for the current folder, just like index.html as an entry file.

When we visit the link at this point, we will see the following page. Because we haven’t added any components yet.

Create the blog post page component

export default function Posts() {
  return (
    <div>
      <h1>Posts</h1>
    </div>
  );
}
Copy the code

After adding the above code, when we click on the Posts link, we’ll see Posts rendered on the page.

Load the data

Data loading is built into Remix.

In a traditional Web project, our API for retrieving data is separate from the front-end components used to render the data. In Remix, the front-end component is our API routing. Of course, if the API interface to get data is provided by other services, the routing layer in Remix can also be used as the front-end data rendering controller. Next we set up some data for our component.

Add useLoaderData to the posts route

import { useLoaderData } from "remix";

export const loader = () = > {
  return[{slug: "my-first-post".title: "My First Post"
    },
    {
      slug: "90s-mixtape".title: "A Mixtape I Made Just For You"}]; };export default function Posts() {
  const posts = useLoaderData();
  console.log(posts);
  return (
    <div>
      <h1>Posts</h1>
    </div>
  );
}
Copy the code

Loaders is the API of the current component and has been encapsulated with useLoaderData. If you open the browser console and the background console at the same time, you’ll see posts printed in the log. This is because Remix renders the page on the server and sends the HTML to the browser for display, while also injecting and entering log data in the front end.

Add a link to the article list

  <ul>
    {posts.map(post= > (
      <li key={post.slug}>
        <Link to={post.slug}>{post.title}</Link>
      </li>
    ))}
  </ul>
Copy the code

Remember to introduce Link in the file header. TypeScript reports an error. Let’s add some types to fix the error.

import { Link, useLoaderData } from "remix";

type Post = {
  slug: string;
  title: string;
};

export const loader = () = > {
  const posts: Post[] = [
    {
      slug: "my-first-post".title: "My First Post"
    },
    {
      slug: "90s-mixtape".title: "A Mixtape I Made Just For You"}];return posts;
};

export default function Posts() {
  const posts = useLoaderData<Post[]>();
  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.slug}>
            <Link to={post.slug}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}
Copy the code

A little bit of refactoring

Based on past experience, it is best to create a module to handle a specific scenario. In our example, it involves reading blogs and adding blogs. Let’s start creating them. Create a getPosts method and export it in our POST module.

// Create post file: app/post.ts
Copy the code
export type Post = {
  slug: string;
  title: string;
};

export function getPosts() {
  const posts: Post[] = [
    {
      slug: "my-first-post".title: "My First Post"
    },
    {
      slug: "90s-mixtape".title: "A Mixtape I Made Just For You"}];return posts;
}
Copy the code

Modify the posts route. Use our Post module in routing

// posts/index.jsx
import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";

export const loader = () = > {
  return getPosts();
};

// ...
Copy the code

Pull data from the data source

In the actual project, we will choose the data storage mode according to the actual needs. Will choose to use the appropriate database, such as Postgres, FaunaDB, Supabase. However, in this experience, we will use the file system.

Create a Posts folder under the project root and create some blog posts in MarkDown format in that folder

mkdir posts

touch posts/my-first-post.md
touch posts/90s-mixtape.md

Copy the code

Feel free to put some content in these MD files. But make sure there’s a front matter property with title in it.

Modify the getPosts method to read content from the file system

We’ll use a Node module:

npm add front-matter
Copy the code

Modify the app/posts file as follows:

import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";

export type Post = {
  slug: string;
  title: string;
};

// relative to the server output not the source!
const postsPath = path.join(__dirname, ".."."posts");

export async function getPosts() {
  const dir = await fs.readdir(postsPath);
  return Promise.all(
    dir.map(async filename => {
      const file = await fs.readFile(
        path.join(postsPath, filename)
      );
      const { attributes } = parseFrontMatter(
        file.toString()
      );
      return {
        slug: filename.replace(/\.md$/.""),
        title: attributes.title }; })); }Copy the code

TypeScript should now report an error. Let’s fix the mistake.

Since we get the content by reading the file, type checking does not know what type of data is inside. So we need runtime checks. We’ll bring in Invariant to help us deal with this problem more easily.

The content of the app/post.ts file is as follows:

import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";
import invariant from "tiny-invariant";

export type Post = {
  slug: string;
  title: string;
};

export type PostMarkdownAttributes = {
  title: string;
};

const postsPath = path.join(__dirname, ".."."posts");

function isValidPostAttributes(
  attributes: any
) :attributes is PostMarkdownAttributes {
  returnattributes? .title; }export async function getPosts() {
  const dir = await fs.readdir(postsPath);
  return Promise.all(
    dir.map(async filename => {
      const file = await fs.readFile(
        path.join(postsPath, filename)
      );
      const { attributes } = parseFrontMatter(
        file.toString()
      );
      invariant(
        isValidPostAttributes(attributes),
        `${filename}has bad meta data! `
      );
      return {
        slug: filename.replace(/\.md$/.""),
        title: attributes.title }; })); }Copy the code

Even if we are not using TS, we will want to use invariant to know exactly where the error was reported. When we go to visit http://localhost:3000/posts, we can see the reading list of articles from the file system. Feel free to add other articles to see how the data changes.

Dynamic routing parameters

Next, let’s create a route to the specific article. We want the following routes to work:

/posts/my-first-post
/posts/90s-mix-cdr
Copy the code

We don’t have to create a route for every article. Instead, dynamic route identifiers are used in urls. Remix parses and passes dynamic parameters to the route.

Create a dynamic routing file: app/routes/posts/$slug.tsx

export default function PostSlug() {
  return (
    <div>
      <h1>Some Post</h1>
    </div>
  );
}
Copy the code

Add a Loader access parameter

import { useLoaderData } from "remix";

export const loader = async ({ params }) => {
  return params.slug;
};

export default function PostSlug() {
  const slug = useLoaderData();
  return (
    <div>
      <h1>Some Post: {slug}</h1>
    </div>
  );
}
Copy the code

The value after the $sign on the route will be used as the key value in the Loader parameter params. Add some TypeScript type validation:

import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";

export const loader: LoaderFunction = async ({
  params
}) => {
  return params.slug;
};
Copy the code

Next, let’s read the article from the file system.

Add the getPost method to the POST module

// ...
export async function getPost(slug: string) {
  const filepath = path.join(postsPath, slug + ".md");
  const file = await fs.readFile(filepath);
  const { attributes } = parseFrontMatter(file.toString());
  invariant(
    isValidPostAttributes(attributes),
    `Post ${filepath} is missing attributes`
  );
  return { slug, title: attributes.title };
}
Copy the code

Use the new getPost method in routing

// routes/posts/$slug.tsx
import { useLoaderData } from "remix";
import type { LoaderFunction } from "remix";
import { getPost } from "~/post";
import invariant from "tiny-invariant";

export const loader: LoaderFunction = async ({
  params
}) => {
  invariant(params.slug, "expected params.slug");
  return getPost(params.slug);
};

export default function PostSlug() {
  const post = useLoaderData();
  return (
    <div>
      <h1>{post.title}</h1>
    </div>
  );
}
Copy the code

Since the parameters in params may or may not be values and may not be slug, we still use invariant to make false judgments. It also allows TS to avoid errors.

We use marked to resolve markdown.

NPM add @types/marked # if using typescript, the following packages need to be installedCopy the code

Render HTML in the route

// ...
export default function PostSlug() {
  const post = useLoaderData();
  return (
    <div dangerouslySetInnerHTML={{ __html: post.html}} / >
  );
}
Copy the code

At this point, we can celebrate with champagne blossoms, we have our own blog!

Create a Blog post

Our above blog system is ready for deployment. But our best bet is to store the blog post data in a database so that we don’t have to go online with any changes. So we need an entry point to create an article, and we’ll use form submission.

Create an admin route

touch app/routes/admin.tsx
Copy the code
import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";

export const loader = () = > {
  return getPosts();
};

export default function Admin() {
  const posts = useLoaderData<Post[]>();
  return (
    <div className="admin">
      <nav>
        <h1>Admin</h1>
        <ul>
          {posts.map(post => (
            <li key={post.slug}>
              <Link to={` /posts/ ${post.slug} `} >
                {post.title}
              </Link>
            </li>
          ))}
        </ul>
      </nav>
      <main>.</main>
    </div>
  );
}
Copy the code

Aside from adding some extra HTML structure, you’ll notice that most of the content in admin.tsx is copied from the Posts route. We will make some style changes next.

Create an Admin.css style file

touch app/styles/admin.css
Copy the code
.admin {
  display: flex;
}

.admin > nav {
  padding-right: 2rem;
}

.admin > main {
  flex: 1;
  border-left: solid 1px #ccc;
  padding-left: 2rem;
}

em {
  color: red;
}
Copy the code

Associate style files with admin routes

import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
import type { Post } from "~/post";
import adminStyles from "~/styles/admin.css";

export const links = () = > {
  return [{ rel: "stylesheet".href: adminStyles }];
};

// ...
Copy the code

Preview the admin route as follows:

Each route can export a Links method that returns a link array. We use {rel: “stylesheet”, href: adminStyles} instead of . This allows Remix to merge the already rendered route collection and render it in at the top of the page. We should now see a page display with a list of articles on the left and a booth on the right. You can manually visit http://localhost:3000/admin this routing.

Root route (Index Routes)

Let’s create an index route for Admin. We will introduce the use of nested routines.

Create a folder for the subroutes of the admin route and create an index.tsx in it

mkdir app/routes/admin
touch app/routes/admin/index.tsx
Copy the code
import { Link } from "remix";

export default function AdminIndex() {
  return (
    <p>
      <Link to="new">Create a New Post</Link>
    </p>
  );
}
Copy the code

If we refresh the browser at this point, we don’t see the content we just created. All routes under app/routes/admin/ will be rendered in app/routes/admin.tsx when their routes match. You need to control how admin displays these matching routes.

Add outlets to the Admin page

// admin.tsx

import { Outlet, Link, useLoaderData } from "remix";

/ /...
export default function Admin() {
  const posts = useLoaderData<Post[]>();
  return (
    <div className="admin">
      <nav>
        <h1>Admin</h1>
        <ul>
          {posts.map(post => (
            <li key={post.slug}>
              <Link to={` /posts/ ${post.slug} `} >
                {post.title}
              </Link>
            </li>
          ))}
        </ul>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}
Copy the code

When the URL matches the path of the parent route, the index routes will be rendered in the outlet. Next let’s add the /admin/new route and click Create a new Post to see what happens.

Create app/routes/admin/new.tsx routes

touch app/routes/admin/new.tsx
Copy the code
export default function NewPost() {
  return <h2>New Post</h2>;
}
Copy the code

When we click Create a new Post, we will see that the route is to admin/new and the content has changed, rendering the content except admin/new in our outlet.

Form submission (Actions)

The next big thing we’re going to do is create a form in the New route to submit a new blog post.

Add a form form to the new route

import { Form } from "remix";

export default function NewPost() {
  return (
    <Form method="post">
      <p>
        <label>
          Post Title: <input type="text" name="title" />
        </label>
      </p>
      <p>
        <label>
          Post Slug: <input type="text" name="slug" />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">Markdown:</label>
        <br />
        <textarea id="markdown" rows={20} name="markdown" />
      </p>
      <p>
        <button type="submit">Create Post</button>
      </p>
    </Form>
  );
}
Copy the code

This is just like the submission form we wrote earlier. Let’s create the necessary code to submit an article in the post.ts module.

Add createPost anywhere in app/post.ts

// ...
export async function createPost(post) {
  const md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`;
  await fs.writeFile(
    path.join(postsPath, post.slug + ".md"),
    md
  );
  return getPost(post.slug);
}
Copy the code

Call the createPost method in the action of the new route

import { redirect, Form } from "remix";
import { createPost } from "~/post";

export const action = async ({ request }) => {
  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");

  await createPost({ title, slug, markdown });

  return redirect("/admin");
};

export default function NewPost() {
  // ...
}
Copy the code

Resolve TS error:

// app/post.ts
type NewPost = {
  title: string;
  slug: string;
  markdown: string;
};

export async function createPost(post: NewPost) {
  const md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`;
  await fs.writeFile(
    path.join(postsPath, post.slug + ".md"),
    md
  );
  return getPost(post.slug);
}

/ /...
Copy the code
import { Form, redirect } from "remix";
import type { ActionFunction } from "remix";
import { createPost } from "~/post";

export const action: ActionFunction = async ({
  request
}) => {
  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");

  await createPost({ title, slug, markdown });

  return redirect("/admin");
};
Copy the code

Whether we use TS or not, submitting when the user has not entered a form field can be problematic. Before we submit the form, let’s add some validation.

Verify that the form contains the required data, and return an error message if the verification fails

/ /...
export const action: ActionFunction = async ({
  request
}) => {
  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");

  const errors = {};
  if(! title) errors.title =true;
  if(! slug) errors.slug =true;
  if(! markdown) errors.markdown =true;

  if (Object.keys(errors).length) {
    return errors;
  }

  await createPost({ title, slug, markdown });

  return redirect("/admin");
};
Copy the code

Notice that we are not returning the redirect message at this point. Instead, an error message was returned. Within the component, this information can be accessed via useActionData. It’s very similar to useLoaderData. However, the data is retrieved via action after the form is submitted.

Add verification information display on UI

import {
  useActionData,
  Form,
  redirect,
  ActionFunction
} from "remix";

// ...

export default function NewPost() {
  const errors = useActionData();

  return (
    <Form method="post">
      <p>
        <label>Post Title:{" "} {errors? .title &&<em>Title is required</em>}
          <input type="text" name="title" />
        </label>
      </p>
      <p>
        <label>Post Slug:{" "} {errors? .slug &&<em>Slug is required</em>}
          <input type="text" name="slug" />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">Markdown:</label>{" "} {errors? .markdown &&<em>Markdown is required</em>}
        <br />
        <textarea rows={20} name="markdown" />
      </p>
      <p>
        <button type="submit">Create Post</button>
      </p>
    </Form>
  );
}
Copy the code

Here’s the interesting part: When disabling JavaScript in developer tools, try again. Since Remix is built over HTTP and HTML, it still works fine in the browser when JavaScript is disabled. That’s not the point, as we slow down the processing of the data and add some loading UI to the form form.

Slow down our action with a simulated delay

// ...
export const action: ActionFunction = async ({
  request
}) => {
  await new Promise(res= > setTimeout(res, 1000));

  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");
  // ...
};
/ /...
Copy the code

Use useTransition to add the loaded UI

import {
  useTransition,
  useActionData,
  Form,
  redirect
} from "remix";

// ...

export default function NewPost() {
  const errors = useActionData();
  const transition = useTransition();

  return (
    <Form method="post">{/ *... * /}<p>
        <button type="submit">
          {transition.submission
            ? "Creating..."
            : "Create Post"}
        </button>
      </p>
    </Form>
  );
}
Copy the code

Users can now have a great experience without JavaScript support.

The ~