Building Dynamic Web Applications with Remix.js, Drizzle ORM and Tailwind

franciscomendes10866

Francisco Mendes

Posted on May 2, 2023

Building Dynamic Web Applications with Remix.js, Drizzle ORM and Tailwind

What you will learn

I hope this article helps you create a server-side rendered application using Remix. In addition to giving some tips on how to analyze the routes to have a safer approach, handle forms more easily and make a CRUD in a database.

end result

What does this article cover

We will cover several aspects, including:

  • Tailwind Configuration
  • Configure a type safe helper for safe route usage
  • Form validation
  • Data modeling
  • Create, Read, Update and Delete (CRUD) operations

Prerequisites

Before starting the article, it is recommended that you have knowledge of React, Remix, Tailwind and a prior knowledge of nested routes and ORM's.

Creating the Project

To initialize a project in Remix we execute the following command:



npx create-remix@latest remizzle
cd remizzle


Enter fullscreen mode Exit fullscreen mode

We start the dev server with the following command:



npm run dev


Enter fullscreen mode Exit fullscreen mode

Set up Tailwind

We are going to use the Just the basics type, with a deployment target of Remix App Server and we are going to use TypeScript in the application.

Next, let's configure Tailwind in our project, starting with the installation:



npm install -D tailwindcss
npx tailwindcss init


Enter fullscreen mode Exit fullscreen mode

Then we add the following in the remix.config.js file:



module.exports = {
  future: {
    unstable_tailwind: true,
  },
};


Enter fullscreen mode Exit fullscreen mode

We also make the following changes to tailwind.config.js:



module.exports = {
  content: ["./app/**/*.{ts,tsx}"],
  // ...
};


Enter fullscreen mode Exit fullscreen mode

Next, we create a file called tailwind.css inside the app/ folder with the following:



@tailwind base;
@tailwind components;
@tailwind utilities;


Enter fullscreen mode Exit fullscreen mode

To make app development easier, let's take advantage of the pre-styled components of the daisyUI library, starting by installing the dependency:



npm install daisyui


Enter fullscreen mode Exit fullscreen mode

Then we add the library to the list of plugins in the tailwind.config.js file in which we can also define which theme we want to use, as follows:



module.exports = {
  // ...
  plugins: [require("daisyui")],
  daisyui: {
    themes: ["dracula"],
  },
};


Enter fullscreen mode Exit fullscreen mode

Now, in the root.tsx file inside the app/ folder, import the css file and add it to the links variable:



// @/app/root.tsx
import type { LinksFunction } from "@remix-run/node";
// ...

import stylesheet from "~/tailwind.css";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: stylesheet },
];

// ...


Enter fullscreen mode Exit fullscreen mode

Set up Routes Gen

Remix uses file based routing, in which we define our application's routes through files that are created inside the routes/ folder, with nested routes and URL parameters in the mix.

But in fact we don't have any type safety when we are navigating to a certain route or if we are missing some parameter, for that same reason we will take advantage of Routes Gen in this app.

First install the necessary dependencies:



npm install routes-gen @routes-gen/remix --dev 


Enter fullscreen mode Exit fullscreen mode

In package.json we create the following script:



{
  // ...
  "scripts": {
    // ...
    "routes:gen": "routes-gen -d @routes-gen/remix"
  },
  // ...
}


Enter fullscreen mode Exit fullscreen mode

When we run the command, it will create the routes.d.ts file inside the app/ folder with the data type declarations taking into account the application's routes. Remembering that the command is this:



npm run routes:gen


Enter fullscreen mode Exit fullscreen mode

Set up Drizzle ORM

To make this article accessible to more people, we are going to use the SQLite database and install the following dependencies:



npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3


Enter fullscreen mode Exit fullscreen mode

The next step will be to create the config.server.ts file in a folder called db/ inside the app/ folder, with the following:



// @/app/db/config.server.ts
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import Database from "better-sqlite3";

const sqlite = new Database("sqlite.db");

export const db = drizzle(sqlite);

migrate(db, { migrationsFolder: "./app/db/migrations" });


Enter fullscreen mode Exit fullscreen mode

As you may have noticed in the code block above, we start by defining the database, then we use the SQLite driver and export its instance (db).

Shortly afterwards we used the migrator function to carry out automatic migrations in our database taking into account what is generated inside the app/db/migrations/ folder.

Then we define our database tables in the schema.server.ts file, also inside the db/ folder, taking advantage of drizzle primitives and exporting the schema that was created:



// @/app/db/schema.server.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const dogs = sqliteTable("dogs", {
  id: integer("id").primaryKey(),
  name: text("name").notNull(),
  breed: text("breed").notNull(),
});


Enter fullscreen mode Exit fullscreen mode

The last step in this section would be to create the following script in package.json:



{
  // ...
  "scripts": {
    // ...
    "db:migrations": "drizzle-kit generate:sqlite --out ./app/db/migrations --schema ./app/db/schema.server.ts"
  },
  // ...
}


Enter fullscreen mode Exit fullscreen mode

Running the above command will generate migrations inside the app/db/migrations folder that can be applied to the database. With this we can go to the next section.

Modify and Build the Main Routes

In this section we will make some changes to existing pages, but we will also create new pages and new reusable components.

Layout Page

First of all, let's create a file called dogs.tsx inside the routes/ folder that will be the layout of our application, with the following:



// @/app/routes/dogs.tsx
import { type V2_MetaFunction } from "@remix-run/node";
import { Outlet } from "@remix-run/react";

export const meta: V2_MetaFunction = () => {
  return [{ title: "Dog List Page" }];
};

export default function DogsLayout() {
  return (
    <div className="m-12">
      <Outlet />
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

In the example above we have a JSX component that returns only one Outlet and this will be responsible for rendering each of our app's nested routes, whose path starts with /dogs.

App Index Page Redirect

Next, we go to the main route of our application to remove the JSX content, because we want a redirection from the / route to the /dogs route.

In the _index.tsx file, we do the following:



// @/app/routes/_index.tsx
import { redirect } from "@remix-run/node";
import { route } from "routes-gen";

export const loader = () => redirect(route("/dogs"));


Enter fullscreen mode Exit fullscreen mode

In order to have type safety with the routes, I recommend running the routes:gen command frequently to have the routes data types updated to take advantage of intellisense.

Create-page

In this section we will create a reusable component called Input but first we need to install the following dependencies:



npm install remix-validated-form zod @remix-validated-form/with-zod


Enter fullscreen mode Exit fullscreen mode

remix-validated-form is the form validation library that we are going to use, it is very simple, intuitive and has a collection of utility functions to help us deal with forms.

Then we create the following component inside the app/components/ folder:



// @/app/components/Input.tsx
import type { FC, InputHTMLAttributes } from "react";
import { useField } from "remix-validated-form";

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  name: string;
  label: string;
}

export const Input: FC<InputProps> = ({ name, label, ...rest }) => {
  const { error, getInputProps } = useField(name);

  return (
    <span className="flex flex-col">
      <label htmlFor={name} className="mb-3">
        {label}
      </label>
      <input
        className="input input-bordered w-full max-w-xs"
        {...rest}
        {...getInputProps({ id: name })}
      />

      {error && <span className="label-text-alt mt-3">{error}</span>}
    </span>
  );
};


Enter fullscreen mode Exit fullscreen mode

With this component we can move on to creating the page responsible for inserting a new doggo. The file to be created in the routes/ folder is dogs.create.tsx with the following content:



// @/app/routes/dogs.create.tsx
import type { DataFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { ValidatedForm, validationError } from "remix-validated-form";
import { route } from "routes-gen";
import { z } from "zod";

import { Input } from "~/components/Input";
import { db } from "~/db/config.server";
import { dogs } from "~/db/schema.server";

const validator = withZod(
  z.object({
    name: z.string().min(1).max(34),
    breed: z.string().min(1).max(34),
  })
);

export const loader = () => {
  return json({
    defaultValues: {
      name: "",
      breed: "",
    },
  });
};

export const action = async ({ request }: DataFunctionArgs) => {
  const fieldValues = await validator.validate(await request.formData());
  if (fieldValues.error) return validationError(fieldValues.error);

  db.insert(dogs).values(fieldValues.data).run();

  return redirect(route("/dogs"));
};

export default function DogInsertion() {
  const { defaultValues } = useLoaderData<typeof loader>();

  return (
    <div>
      <div className="mb-8">
        <h2 className="mb-2 text-xl">Add a new doggo</h2>
        <p className="text-gray-600">Listen, every doggo is a good boy/girl.</p>
      </div>

      <ValidatedForm
        className="space-y-6"
        method="POST"
        validator={validator}
        defaultValues={defaultValues}
      >
        <Input name="name" label="Name" placeholder="Your doggo's name..." />
        <Input name="breed" label="Breed" placeholder="Your doggo's breed..." />

        <button className="btn btn-accent" type="submit">
          Submit
        </button>
      </ValidatedForm>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

In the code block above, we started by defining the validator variable taking into account the validation schema of the form that needs to be filled in.

Then, using the loader function, we return the default values of the form and with the action function, we obtain the data that were entered by the form and validate them.

If the data is valid, we insert the new doggo and redirect the user, otherwise we deal with validation errors.

Navigation

With the main components created, we can work on the Navbar which will help the user navigate between the list and insertion pages. So that it can later be used in root.tsx.



// @/app/components/Navbar.tsx
import { Link } from "@remix-run/react";
import { route } from "routes-gen";

export const Navbar = () => (
  <nav className="navbar bg-base-300 rounded-xl mt-4">
    <div className="flex w-full items-center">
      <ul className="menu menu-horizontal px-1 space-x-4">
        <li>
          <Link to={route("/dogs")}>Dog List</Link>
        </li>
        <li>
          <Link to={route("/dogs/create")}>New Dog</Link>
        </li>
      </ul>
    </div>
  </nav>
);


Enter fullscreen mode Exit fullscreen mode

As mentioned earlier, the next step is to import this newly created component into root.tsx:



// @/app/root.tsx

// ...
import { Navbar } from "./components/Navbar";

// ...

export default function App() {
  return (
    <html lang="en">
      {/* ... */}
      <body>
        <div className="container mx-auto"> 
          <Navbar /> {/* 👈 added this line */}
          <Outlet />
        </div>

        {/* ... */}
      </body>
    </html>
  );
}


Enter fullscreen mode Exit fullscreen mode

Create the remaining routes

The effort in this section involves creating each of the remaining routes of our application, taking advantage of everything that has been done so far.

Details Page

The purpose of this route is to display the data of a specific doggo taking into account the dogId parameter in the route. Let's take advantage of the loader function, in which we query the database and then return them so that they can be consumed in the UI using the useLoaderData hook.



// @/app/routes/dogs.$dogId.details.tsx
import { json, type LoaderArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { eq } from "drizzle-orm/expressions";
import type { RouteParams } from "routes-gen";

import { db } from "~/db/config.server";
import { dogs } from "~/db/schema.server";

export const loader = ({ params }: LoaderArgs) => {
  const { dogId } = params as RouteParams["/dogs/:dogId/details"];

  const result = db
    .select()
    .from(dogs)
    .where(eq(dogs.id, Number(dogId)))
    .get();

  return json(result);
};

export default function DogDetailsMain() {
  const data = useLoaderData<typeof loader>();

  return (
    <div className="w-3/5 lg:w-2/5">
      <h2 className="mb-6">Dog details:</h2>

      <div className="mockup-code">
        <pre data-prefix="~">
          <code>Name: {data.name}</code>,&nbsp;
          <code>Breed: {data.breed}</code>
        </pre>
      </div>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

Remove-page

The purpose of this page is to allow the user to remove a specific doggo. To do so, we will take advantage of the action function that will take the dogId parameter into account and we will delete the doggo from the database, after which it must be redirected to the main route of the nested routes.



// @/app/routes/dogs.$dogId.remove.tsx
import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { eq } from "drizzle-orm/expressions";
import { route, type RouteParams } from "routes-gen";

import { db } from "~/db/config.server";
import { dogs } from "~/db/schema.server";

export const action = ({ params }: ActionArgs) => {
  const { dogId } = params as RouteParams["/dogs/:dogId/remove"];

  db.delete(dogs)
    .where(eq(dogs.id, Number(dogId)))
    .run();

  return redirect(route("/dogs"));
};

export default function DogDetailsRemove() {
  return (
    <div>
      <label htmlFor="my-modal-6" className="btn btn-wide">
        Delete
      </label>

      <input type="checkbox" id="my-modal-6" className="modal-toggle" />
      <div className="modal modal-bottom sm:modal-middle">
        <div className="modal-box">
          <h3 className="font-bold text-lg">Delete Database Entry</h3>
          <p className="py-4">
            Are you sure you want to delete this database entry? This action is
            irreversible.
          </p>
          <div className="modal-action">
            <Form method="POST">
              <input type="hidden" name="_method" value="delete" />
              <button className="btn">Yes</button>
            </Form>
          </div>
        </div>
      </div>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

Update-page

The purpose of this route is to display data for a specific doggo taking into account the dogId parameter in the route. Using the loader we will obtain a doggo existing in the database and we will return it as JSON so that we have a previous filling of the form. As soon as changes are made, they are submitted and the record in the database is updated using an action.



// @/app/routes/dogs.$dogId.update.tsx
import type { DataFunctionArgs, LoaderArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { ValidatedForm, validationError } from "remix-validated-form";
import type { RouteParams } from "routes-gen";
import { route } from "routes-gen";
import { eq } from "drizzle-orm/expressions";
import { z } from "zod";

import { Input } from "~/components/Input";
import { db } from "~/db/config.server";
import { dogs } from "~/db/schema.server";

const validator = withZod(
  z.object({
    name: z.string().min(1).max(34),
    breed: z.string().min(1).max(34),
  })
);

export const loader = ({ params }: LoaderArgs) => {
  const { dogId } = params as RouteParams["/dogs/:dogId/update"];

  const { id, ...rest } = db
    .select()
    .from(dogs)
    .where(eq(dogs.id, Number(dogId)))
    .get();

  return json({ defaultValues: rest });
};

export const action = async ({ request, params }: DataFunctionArgs) => {
  const fieldValues = await validator.validate(await request.formData());
  const { dogId } = params as RouteParams["/dogs/:dogId/update"];

  if (fieldValues.error) {
    return validationError(fieldValues.error);
  }

  db.update(dogs)
    .set(fieldValues.data)
    .where(eq(dogs.id, Number(dogId)))
    .run();

  return redirect(route("/dogs"));
};

export default function DogDetailsUpdate() {
  const { defaultValues } = useLoaderData<typeof loader>();

  return (
    <div>
      <h2 className="mb-6 text-xl">Update doggo entry</h2>

      <ValidatedForm
        className="space-y-6"
        method="POST"
        validator={validator}
        defaultValues={defaultValues}
      >
        <Input name="name" label="Name" placeholder="Your doggo's name..." />
        <Input name="breed" label="Breed" placeholder="Your doggo's breed..." />
        <button className="btn btn-accent" type="submit">
          Update
        </button>
      </ValidatedForm>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

Dog Details Layout

Now that we have each of the pages that make it possible to mutate the data in the database taking into account a specific doggo, we need to create navigation between the different pages that were created (details, remove and update).

Again, let's use the route primitive from routes-gen to autocomplete the editor to ensure that we define the necessary parameters for each of the routes.

Obviously we create each of the navigation items taking into account the created routes and use the Outlet primitive to render each of the nested routes.



// @/app/routes/dogs.$dogId.tsx
import type { V2_MetaFunction } from "@remix-run/react";
import { Link, Outlet, useParams } from "@remix-run/react";
import type { RouteParams } from "routes-gen";
import { route } from "routes-gen";

export const meta: V2_MetaFunction = () => {
  return [{ title: "Dog Details Page" }];
};

export default function DogDetails() {
  const { dogId } = useParams<RouteParams["/dogs/:dogId"]>();

  return (
    <div className="flex flex-row space-x-12">
      <ul className="menu bg-base-100 w-56 rounded-box">
        <li>
          <Link
            to={route("/dogs/:dogId/details", {
              dogId: dogId ?? "",
            })}
          >
            Details
          </Link>
        </li>
        <li>
          <Link
            to={route("/dogs/:dogId/update", {
              dogId: dogId ?? "",
            })}
          >
            Update
          </Link>
        </li>
        <li className="text-red-600">
          <Link
            to={route("/dogs/:dogId/remove", {
              dogId: dogId ?? "",
            })}
          >
            Remove
          </Link>
        </li>
      </ul>

      <Outlet />
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

Main Route

If you've followed the article so far, we've already created each of the routes to do the CRUD of the application, the only thing missing is to list the dogs that were added through the app in the database. To do so, let's create the following dogs._index.tsx:



// @/app/routes/dogs.$dogId.tsx
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { route } from "routes-gen";

import { db } from "~/db/config.server";
import { dogs } from "~/db/schema.server";

export const loader = () => {
  const result = db.select().from(dogs).all();
  return json(result);
};

export default function DogList() {
  const data = useLoaderData<typeof loader>();

  return (
    <div className="overflow-x-auto">
      <table className="table table-zebra w-full">
        <thead>
          <tr>
            <th>ID</th>
            <th>Name</th>
            <th>Breed</th>
          </tr>
        </thead>

        <tbody>
          {data?.map((item) => (
            <tr key={item.id}>
              <th>
                <Link
                  to={route("/dogs/:dogId/details", {
                    dogId: item.id.toString(),
                  })}
                >
                  <kbd className="kbd">{item.id}</kbd>
                </Link>
              </th>
              <td>{item.name}</td>
              <td>{item.breed}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

As you may have noticed in the code block above, we get all the data from the dogs table in the loader and return them so that we can render each of the table's rows on the page, as well as have a link to navigate to the pages of a doggo in detail.

Conclusion

I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.

Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.

Github Repo

💖 💪 🙅 🚩
franciscomendes10866
Francisco Mendes

Posted on May 2, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related