Skip to content

First exercises

rocambille edited this page Sep 6, 2026 · 20 revisions

Summary: To get started with StartER, here are some practical exercises to complete after installation.

What you'll learn:

  • Create an Express API endpoint with a test
  • Create a React page with routing
  • Connect the frontend to the backend
  • Modify an existing resource across the full stack
  • Update tests to reflect your changes

Objectives

They'll allow you to verify that your environment is functional and experiment with the framework's essential principles, from Express routing to React integration.

Complete the exercises

Exercise 1: Hello API

Let's start with a minimalist endpoint that returns "hello, world!". This exercise validates your setup and introduces the basics of routing with Express.

Step 1: write the test first

Create a hello folder in src/express/modules. Then, create a hello.test.ts file inside it to define what we expect from our API before we build it:

import express from "express";
import request from "supertest";

import routes from "../../routes";

const app = express();
app.use(routes);

describe("Hello API", () => {
  it("should return hello world", async () => {
    const response = await request(app).get("/api/hello");

    expect(response.status).toBe(200);
    expect(response.body).toEqual({ message: "hello, world!" });
  });
});

The test will fail because the route doesn't exist yet.

Open a terminal in the root folder of the application and run the following command:

npm run test

See the failure:

Test Files 1 failed

This is the first step of Test-Driven Development (TDD).

Step 2: create a routes file

Now, let's make the test pass. Create a helloRoutes.ts file in the src/express/modules/hello folder with the following contents:

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

const router = Router();

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

const sayHello: RequestHandler = (_req, res) => {
  res.json({ message: "hello, world!" });
};

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

router.get("/api/hello", sayHello);

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

export default router;

Step 3: integrate the module into the main routes

Modify the src/express/routes.ts file to integrate your new module at the end of the file:

// ...

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

import helloRoutes from "./modules/hello/helloRoutes";

router.use(helloRoutes);

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

export default router;

Step 4: verify the endpoint

Run npm run test again. The test should now pass! You can also restart your server if necessary and visit http://localhost:5173/api/hello in your browser or use a tool like cURL or Postman. You should see the JSON response.

🎉 If everything works, congratulations: your first Express route is up and running!

Warning

About testing mutations (POST, PUT, DELETE): the test above uses a GET request, which works without any special setup. If you try to test a POST, PUT, or DELETE request, you will get a 401 Unauthorized error. This is because StartER protects mutation routes with CSRF tokens. You'll learn how to handle this in Creating your first tests. For now, stick with GET requests in your tests.

Exercise 2: Create an "About" page

Now let's move on to the client side: we'll create a page and connect it to React Router's routing system.

Step 1: create a page component

Create an About.tsx file in the src/react/components folder:

function About() {
  return (
    <>
      <h1>About</h1>
      <p>StartER rocks !</p>
    </>
  );
}

export default About;

Step 2: add the route to the routes.tsx file

Modify the src/react/routes.tsx file to integrate your new page:

// ...

import About from "./components/About";

// ...

import "./index.css";

const routes: RouteObject[] = [
  {
    // ...
    children: [
      {
        index: true,
        element: <Home />,
      },
      {
        path: "/about",
        element: <About />,
      },
      // ...
    ],
  },
];

export default routes;

Step 3: update the navigation

Modify the NavBar component to include a link to your new page. Open the src/react/components/NavBar.tsx file and add a new element to the navigation:

<nav>
  <ul>
    {link("/", "Home")}
    {link("/about", "About")}
    { /* ... */ }
  </ul>
</nav>

Step 4: test the Page

Visit http://localhost:5173/about in your browser (you may need to run npm run dev in a terminal if not already running). You should see your new page.

🎉 If the page displays correctly, you have successfully validated client-side routing and the integration of a standalone component.

Exercise 3: Connecting the frontend to the backend

In this last exercise, we will connect the frontend and the backend: the React page will display the message returned by your Express API.

Step 1: add the fetch functionality to About.tsx

Modify the About.tsx component to add a call to the /api/hello endpoint:

import { use } from "react";

import { getOrFetch } from "../helpers/cache";

function About() {
  const { message } = use(getOrFetch<{ message: string }>("/api/hello"));

  return (
    <>
      <h1>About</h1>
      <p>StartER rocks !</p>
      <p>Message from the API : "{message}"</p>
    </>
  );
}

export default About;

Note

How getOrFetch() works: getOrFetch() is a helper that fetches an API URL and stores the result in memory. If the same URL is requested again, it returns the stored result instead of making another network call. React's use() hook reads the result and suspends the component until the data arrives. You'll find a full explanation in How data flows.

Step 2: test the integration

Reload the http://localhost:5173/about page. You should now see the "hello, world!" message retrieved from your API.

🎉 If you see the "hello, world!" message in your browser, your integration is successful. You've just completed a full loop between the React client and the Express server!

Exercise 4: Extending an existing resource

Now that you can connect the frontend to the backend, let's practice modifying an existing resource. We'll add a description field to the item resource. This exercise walks you through a real modification cycle: database → types → backend → frontend.

Step 1: update the database schema

Open src/database/schema.sql and add a description column to the item table:

create table item (
  id integer primary key not null,
  title varchar(255) not null,
  description text default '',
  created_at datetime default (strftime('%Y-%m-%dT%H:%M:%SZ')),
  deleted_at datetime default null,
  user_id integer not null,
  foreign key(user_id) references user(id) on delete cascade
);

Then reset the database to apply the change:

npm run database:reset

Caution

You will be prompted to confirm the deletion of the database. This command erases all existing data. During prototyping, this is expected and safe.

Step 2: update the Single Source of Truth schema (itemSchemas.ts)

Open src/express/modules/item/itemSchemas.ts and add description to ItemSchema:

export const ItemSchema = z.object({
  id: z.number(),
  title: z.string().max(255),
  description: z.string().max(1000).optional().default(""),
  user_id: z.number(),
});

Because Item is inferred directly from ItemSchema (export type Item = z.infer<typeof ItemSchema>) and re-exported in src/types/index.d.ts, both the ambient global Item type and derived ItemDTOSchema update automatically!

Step 3: update the repository

Open src/express/modules/item/itemRepository.ts. You need to update two things:

  1. The SQL queries in find and findAll to include the new column:

    select id, title, description, user_id from item where ...
  2. The create and update methods to handle description:

    create(item: ItemDTOWithUserId): Item["id"] {
      const result = database
        .prepare("insert into item (title, description, user_id) values (?, ?, ?)")
        .run(item.title, item.description, item.user_id);
    
      return Number(result.lastInsertRowid);
    }

Note

itemRepository.ts imports ItemSchema directly from ./itemSchemas to parse database rows via ItemSchema.parse(row), so output parsing updates automatically.

Step 4: update the React form and components

Open src/react/components/item/ItemForm.tsx and make the following updates:

  1. Update the client-side Zod validation schema (itemSchema) to include description.
  2. Declare a unique ID for the input using useId() (e.g., const descriptionId = useId()).
  3. Add a <label> and <input> for the description field in the form markup.

Note

Object.fromEntries(formData) in the form's action automatically captures all input fields by name, so no changes to FormData extraction are required.

Don't forget to update ItemCreate.tsx to provide an initial default description value in newItem (e.g., description: "").

Step 5: verify the result

Run the development server (npm run dev) and test the full cycle:

  1. Create a new item with a description
  2. Verify the description appears on the item's detail page
  3. Edit the item and change the description

🎉 If everything works, congratulations! You've just completed a full modification cycle across the entire stack: database → types → backend → frontend.

Tip

Notice how TypeScript guided you through the process. After changing the Item type, the compiler told you exactly which files needed updating. This is the power of shared types in StartER.

Exercise 5: Fixing the broken tests

If you run npm run test after Exercise 4, the tests will fail. This is expected! The tests don't know about the new description field yet. This exercise teaches you how to keep tests in sync with your code.

Step 1: understand why the tests fail

Run the tests:

npm run test

The errors point to the contracts (tests/contracts/items.ts) and the fixtures (tests/fixtures/items.ts). These files define the test data and expected API behaviors. They don't know about the description field yet.

Note

In StartER, fixtures (tests/fixtures/) are immutable, frozen TypeScript objects (firstItem, secondItem) representing known test entities. Each fixture file also exports a seeder function (seedItems) used by the test runner to populate a clean, in-memory SQLite database before running contract tests. Because the database schema changed in Exercise 4, both the fixture objects and the seeder's INSERT query must be updated.

Step 2: update the fixtures

Open tests/fixtures/items.ts. You need to add the description field to each item and update the insert query in seedItems:

export const firstItem: Item = Object.freeze({
  id: 1,
  title: "Stuff",
  description: "This is stuff",
  user_id: standardUser.id,
});

export const secondItem: Item = Object.freeze({
  id: 2,
  title: "Doodads",
  description: "These are doodads",
  user_id: userWithAvatar.id,
});

export const allItems: Item[] = Object.freeze([
  firstItem,
  secondItem,
]) as Item[];

export const seedItems = (db: DatabaseSync) => {
  const insertItem = db.prepare(
    "insert into item(id, title, description, user_id) values(?, ?, ?, ?)",
  );
  for (const item of allItems) {
    insertItem.run(item.id, item.title, item.description, item.user_id);
  }
};

Step 3: update the contracts

Open tests/contracts/items.ts. You need to add description to the body of every request that sends data (create and edit cases). Here is the complete updated file:

import { allItems, firstItem } from "../fixtures/items";
import { standardUser, userWithAvatar } from "../fixtures/users";

export default (<Contract>{
  browse: {
    method: "get",
    path: "/api/items",
    cases: {
      success: {
        request: { headers: { Range: "items=0-9" } },
        response: {
          status: 206,
          body: allItems,
          headers: {
            "content-range": `items 0-${allItems.length - 1}/${allItems.length}`,
          },
        },
      },
      no_range: {
        request: {},
        response: { status: 400, body: {} },
      },
      out_of_range: {
        specialPath: "/api/items",
        request: { headers: { Range: "items=9999-9999" } },
        response: {
          status: 416,
          body: {},
          headers: { "content-range": `items */${allItems.length}` },
        },
      },
    },
  },
  create: {
    method: "post",
    path: "/api/items",
    cases: {
      success: {
        request: {
          body: { title: "new title", description: "" },
          jwtPayload: { sub: standardUser.id },
        },
        response: { status: 201, body: { insertId: expect.any(Number) } },
      },
      bad_request: {
        request: { body: {}, jwtPayload: { sub: standardUser.id } },
        response: { status: 400, body: expect.any(Array) },
      },
      unauthorized: {
        request: {
          body: { title: "new title", description: "" },
          jwtPayload: null,
        },
        response: { status: 401, body: {} },
      },
    },
  },
  delete: {
    method: "delete",
    path: `/api/items/${firstItem.id}`,
    cases: {
      success: {
        request: { jwtPayload: { sub: standardUser.id } },
        response: { status: 204, body: {} },
      },
      unauthorized: {
        request: { jwtPayload: null },
        response: { status: 401, body: {} },
      },
      forbidden: {
        request: { jwtPayload: { sub: userWithAvatar.id } },
        response: { status: 403, body: {} },
      },
      not_found: {
        specialPath: `/api/items/${NaN}`,
        request: { jwtPayload: { sub: standardUser.id } },
        response: { status: 204, body: {} },
      },
    },
  },
  edit: {
    method: "put",
    path: `/api/items/${firstItem.id}`,
    cases: {
      success: {
        request: {
          body: { title: "updated title", description: "" },
          jwtPayload: { sub: firstItem.user_id },
        },
        response: { status: 204, body: {} },
      },
      forbidden: {
        request: {
          body: { title: "updated title", description: "" },
          jwtPayload: { sub: userWithAvatar.id },
        },
        response: { status: 403, body: {} },
      },
      not_found: {
        specialPath: `/api/items/${NaN}`,
        request: {
          body: { title: "updated title", description: "" },
          jwtPayload: { sub: standardUser.id },
        },
        response: { status: 404, body: {} },
      },
    },
  },
  read: {
    method: "get",
    path: `/api/items/${firstItem.id}`,
    cases: {
      success: {
        request: {},
        response: { status: 200, body: firstItem },
      },
      not_found: {
        specialPath: `/api/items/${NaN}`,
        request: {},
        response: { status: 404, body: {} },
      },
    },
  },
});

Step 4: (Optional) update component unit tests

If your contract specifies a custom description value (e.g. description: "new description" instead of ""), component tests like tests/react/components/item/ItemCreate.test.tsx and ItemEdit.test.tsx will need to simulate typing into the new input field:

await user.type(
  screen.getByLabelText(/description/i),
  String(requestValue("items", "create", "success", "description")),
);

Step 5: run the tests again

npm run test

🎉 If all tests pass, congratulations! You now understand the role of contracts and fixtures in StartER's verification system.

Tip

Contracts are the source of truth for your API. By updating them before or right after your implementation, you maintain living documentation that automatically verifies your code.

Congratulations!

You have completed the introductory exercises for StartER! You know how to:

  1. Create an API endpoint with Express
  2. Create a React page and connect it to the router
  3. Connect the frontend to the backend to display dynamic data
  4. Modify an existing resource across the full stack
  5. Update tests to reflect your changes

Best practices and use cases

  • Proceed step by step: test each part (backend isolated, then frontend isolated) before attempting to connect them. This greatly facilitates debugging in case of data retrieval issues.
  • Let TypeScript guide you: when modifying a shared type, follow the compiler errors. They show you every file that needs updating.

See also

Clone this wiki locally