Skip to content

Creating your first resource

rocambille edited this page May 1, 2026 · 6 revisions

Summary: This tutorial guides you through creating a complete resource (an "item") from receiving the request on the backend to creating the user interface on the frontend.

The resource concept

In modern web development, a resource represents a business entity (a user, an article, a product, or any item). Managing this resource involves creating access points on the server (via an API) and corresponding client-side views (via React components) to allow creation, read, update, and deletion operations (CRUD).

Part 1: the database with SQLite

In our "Zero-Config" architecture with SQLite, when adding a new resource, the very first thing to do is define its table in the database.

  1. Schema update: open the src/database/schema.sql file and add the CREATE TABLE declaration for your new resource.
  2. Data update (optional): if you wish, you can add some test data (called fixtures or seeds in other ecosystems) to src/database/seeder.sql.
  3. Synchronization: to make your local database.sqlite file take these changes into account, run the following command in your terminal:
npm run database:sync

Tip

🤖 AI Shortcut: Instead of writing SQL by hand, ask your AI agent: "Create a CREATE TABLE statement for a task resource with title, description, and user_id. Add it to schema.sql and run npm run database:sync."

Caution

Attention, this command completely resets your local database. All existing data will be erased and replaced by the data from seeder.sql.

Part 2: the backend with Express

In StartER, the creation of the one server (in the server.ts file) relies on the definition of routes in the src/express/routes.ts file.

const app = express();

// ...

app.use((await import("./src/express/routes")).default);

// ...

The src/express/routes.ts file declares and exports an instance of express.Router.

import { Router } from "express";

const router = Router();

// ...

export default router;

You can define routes with this router mini-app, exactly identically to the main app.

A "simple" Express route combines an HTTP method (get, post...), a URL path, and a callback function.

router.get("/api", (req, res) => { /* ... */ });
/*      ↓     ↓         ↓
     method  path    callback
*/

The (req, res) => { /* ... */ } callback is a function with access to the request (req) and response (res) objects in the application's request lifecycle. This function can:

  • Run code,
  • Make changes to request and response objects,
  • Complete the request-response cycle.

Alternatively, you can separate the callback declaration from the route declaration. Separating declarations allows callback code to be moved to separate files, leaving only route declarations in the routes.ts file: one file = one responsibility.

import { type RequestHandler, Router } from "express";

const router = Router();

/* ************************************************************************ */

// Callback import

import { sendHelloWorld } from "./path/to/module";

// Route declaration

router.get("/api", sendHelloWorld);

/* ************************************************************************ */

export default router;

This practice is the foundation of module construction in the Express part of StartER.

Creating a module

Express is a minimalist framework that does not dictate any particular file organization. In StartER, we chose to structure the Express part with a modular architecture.


Modular architecture consists of grouping functionality by theme, with each module being autonomous and dedicated to a specific resource.


Most often, a theme corresponds to a resource served by the web API. For example, let's build a module together for an item resource (in this example, an item is a fictitious object with a title: the item module is provided by default in the StartER code). The first step is to create an item folder in the src/express/modules folder. The second step is to create a route "sub-file" in our item folder:

src/express
├── routes.ts
└── modules
    └── item
        └── itemRoutes.ts

The itemRoutes.ts file is the entry point for the item module: it must declare and export an isolated router for "item" routes.

import { Router } from "express";

const itemRoutes = Router();

// ...

export default itemRoutes;

You can then use this item module in src/express/routes.ts:

// ...

import itemRoutes from "./modules/item/itemRoutes";

router.use(itemRoutes);

// ...

Tip

The src/express/routes.ts file provides a utility function importAndUse to import and use routes in one call.

const importAndUse = async (path: string) =>
  router.use((await import(path)).default);

// ...

await importAndUse("./modules/item/itemRoutes");

At this stage, the item module will be built "freely" by adding whatever is necessary to the item routes. After a few files, this might result in an item folder like this:

src/express
├── routes.ts
└── modules
    └── item
        ├── itemActions.ts
        ├── itemParamConverter.ts
        ├── itemRepository.ts
        ├── itemRoutes.ts
        └── itemValidator.ts

The "free" way depends on your own practices and habits, and one module doesn't have to look like another. Some habits that might justify adding a file to a module (non-exhaustive list):

  • One file = one responsibility. This is the basic principle that can guide your choices.
  • Isolating reusable functionality across multiple modules.
  • Repeating a similar module construction.

Make modules until you find an organization that speaks to you.

Trust your logic... and be inspired by others.


💡 To create a new Express module from the item module, use the clone script:

npm run make:clone -- src/express/modules/item src/express/modules/post Item Post

This script duplicates all module files and automatically replaces Item identifiers with Post. This gives you a complete Express module ready to customize.

Important

Don't forget to register your routes! The command created the files, but your application doesn't know them yet. You must import and use your new routes in the src/express/routes.ts file (e.g., await importAndUse("./modules/post/postRoutes");).

Tip

🤖 AI Shortcut: make:clone is the best way to give an AI agent the correct context. Prompt: "Clone the item module into post using the CLI, then implement the SQL queries in postRepository.ts to match the new schema."

Isolating actions

Once the routes are in place, it's time to isolate business logic into reusable actions.

To share our logic, when we built our item module, we created a first file next to itemRoutes.ts: an itemActions.ts file to declare the actions used by the "item" routes.

const browse = (req, res) => {
  const items = /* we will tackle this next */;

  res.json(items);
};

export default { browse };

The browse action is importable and usable in itemRoutes.ts:

import { Router } from "express";

const itemRoutes = Router();

/* ************************************************************************ */

import itemActions from "./modules/item/itemActions";

itemRoutes.get("/api/items", itemActions.browse);

/* ************************************************************************ */

export default itemRoutes;

This approach makes your code more readable and facilitates testing actions independently of routes.

A TypeScript word: To enable type inference on req and res objects, we recommend typing your action with Express RequestHandler type.

import type { RequestHandler } from "express";

const browse: RequestHandler = (req, res) => {
  const items = /* we will tackle this next */;

  res.json(items);
};

export default { browse };

👉 To access and manipulate data in your actions, check out: The Repository pattern and Express API and Validation.

Part 3: the frontend with React

Once the API is ready, you need to build the interface.

StartER provides examples of components for manipulating items (an item is here a fictitious object with a title):

  • the <ItemList> component displays a list of items,
  • the <ItemShow> component displays a read-only item and an <ItemDeleteForm> deletion form,
  • the <ItemEdit> component displays an <ItemForm> form for modifying an item,
  • the <ItemCreate> component displays an <ItemForm> form for creating an item.

These components form a CRUD for items, as they provide graphical interfaces for creating, reading, updating, and deleting items.

Finally, an index.tsx file defines the routes related to the items.

import type { RouteObject } from "react-router";

import ItemCreate from "./ItemCreate";
import ItemEdit from "./ItemEdit";
import ItemList from "./ItemList";
import ItemShow from "./ItemShow";

export const itemRoutes: RouteObject[] = [
  {
    path: "/items/new",
    element: <ItemCreate />,
  },
  {
    path: "/items/:id/edit",
    element: <ItemEdit />,
  },
  {
    path: "/items",
    element: <ItemList />,
  },
  {
    path: "/items/:id",
    element: <ItemShow />,
  },
];

The components of this CRUD are located in the item subfolders of the src/react/components folder.

src/react
├── routes.tsx
└── components
    └── item
        ├── index.tsx
        ├── ItemCreate.tsx
        ├── ItemDeleteForm.tsx
        ├── ItemEdit.tsx
        ├── ItemForm.tsx
        ├── ItemList.tsx
        └── ItemShow.tsx

This is an example way of doing things: you are free to adapt it or organize your components entirely differently.


💡 To create a new React module from the item module, use the clone script:

npm run make:clone -- src/react/components/item src/react/components/post Item Post

Tip

🤖 AI Shortcut: Once cloned, ask your AI: "Update the React components in src/react/components/post to use the /api/posts endpoint and display the new fields from the database."

Important

Don't forget to register your routes! The command created the files, but your application doesn't know them yet. You must add your new routes to the array exported by the src/react/routes.tsx file to make them accessible.

List

Objectives:

  • Display the list of items.
  • Provide a link to each item's Show page.
  • Provide a link to the Create page.
import { use } from "react";
import { Link } from "react-router";

import { cache } from "../utils";

function ItemList() {
  const items = use<Item[]>(cache("/api/items"));

  return (
    <>
      <h1>Items</h1>
      <Link to="/items/new">Ajouter</Link>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <Link to={`/items/${item.id}`}>{item.title}</Link>
          </li>
        ))}
      </ul>
    </>
  );
}

export default ItemList;

Show

Objectives:

  • Display the details of the current item (the title).
  • Provide a link to the item's Edit page.
  • Display the deletion form.
import { use } from "react";
import { Link, useParams } from "react-router";

import { cache } from "../utils";
import ItemDeleteForm from "./ItemDeleteForm";

function ItemShow() {
  const { id } = useParams();

  const item = use<Item | null>(cache(`/api/items/${id}`));

  if (item == null) {
    throw new Error("404");
  }

  return (
    <>
      <h1>{item.title}</h1>
      <Link to={`/items/${item.id}/edit`}>Modifier</Link>
      <ItemDeleteForm />
    </>
  );
}

export default ItemShow;

Note

useParams reads the id from the current location (/items/:id).

Delete

Objectives:

  • Display the deletion form.
  • Trigger the delete action upon submission.
import { useNavigate, useParams } from "react-router";

import { useMutate } from "../utils";

function ItemDeleteForm() {
  const mutate = useMutate();
  const navigate = useNavigate();

  const { id } = useParams();

  const deleteItem = async () => {
    const response = await mutate(`/api/items/${id}`, "delete", null, [
      "/api/items",
      `/api/items/${id}`,
    ]);

    if (response.ok) {
      navigate("/items");
    }
  };

  return (
    <form action={deleteItem}>
      <button type="submit">Supprimer</button>
    </form>
  );
}

export default ItemDeleteForm;

Create & Edit

Objectives for the Create page:

  • Display the form.
  • Create a new "empty" item.
import { useNavigate } from "react-router";

import { useMutate } from "../utils";
import ItemForm from "./ItemForm";

function ItemCreate() {
  const mutate = useMutate();
  const navigate = useNavigate();

  const newItem = {
    title: "",
  };

  const addItem = async (partialItem: Omit<Item, "id" | "user_id">) => {
    const response = await mutate("/api/items", "post", partialItem, [
      "/api/items",
    ]);

    if (response.ok) {
      navigate("/items");
    }
  };

  return (
    <ItemForm defaultValue={newItem} action={addItem}>
      <button type="submit">Ajouter</button>
    </ItemForm>
  );
}

export default ItemCreate;

Objectives for the Edit page:

  • Display the form.
  • Pass the existing item provided by the API (cache("/api/items/:id")).
import { use } from "react";
import { useNavigate, useParams } from "react-router";

import { cache, useMutate } from "../utils";
import ItemForm from "./ItemForm";

function ItemEdit() {
  const mutate = useMutate();
  const navigate = useNavigate();
  const { id } = useParams();

  const editItem = async (partialItem: Omit<Item, "id" | "user_id">) => {
    const response = await mutate(`/api/items/${id}`, "put", partialItem, [
      "/api/items",
      `/api/items/${id}`,
    ]);

    if (response.ok) {
      navigate(`/items/${id}`);
    }
  };

  const item = use<Item | null>(cache(`/api/items/${id}`));

  if (item == null) {
    throw new Error("404");
  }

  return (
    <ItemForm defaultValue={item} action={editItem}>
      <button type="submit">Modifier</button>
    </ItemForm>
  );
}

export default ItemEdit;

The same ItemForm component is used for both pages. Props allow you to:

  • Feed it with a new item or an existing item,
  • Choose the action to trigger upon submission.
import { type PropsWithChildren, useId } from "react";
import { z } from "zod";

const itemSchema = z.object({
  title: z.string().min(1, "The title is required"),
});

interface ItemFormProps extends PropsWithChildren {
  defaultValue: Omit<Item, "id" | "user_id">;
  action: (partialItem: Omit<Item, "id" | "user_id">) => void;
}

function ItemForm({ children, defaultValue, action }: ItemFormProps) {
  const titleId = useId();

  return (
    <form
      aria-label="item form"
      action={(formData) => {
        const title = formData.get("title")?.toString() ?? "";

        const parsed = itemSchema.safeParse({ title });

        if (!parsed.success) {
          alert(z.prettifyError(parsed.error));
          return;
        }

        action(parsed.data);
      }}
    >
      <p>
        <label htmlFor={titleId}>title</label>
        <input
          id={titleId}
          type="text"
          name="title"
          defaultValue={defaultValue.title}
        />
      </p>

      {children}
    </form>
  );
}

export default ItemForm;

This pattern makes the code more reusable: a single component handles input and submission, regardless of the page where it is used.

Best practices and use cases

  • One file = one responsibility: always separate route declarations from callbacks by isolating your actions.
  • Modular architecture: group your files by resource (item, user, etc.) rather than by file type.
  • Reusable components: centralize form logic into a single component populated by different props depending on the case (creation, editing).

See also

Clone this wiki locally