Repository navigation
First exercises
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
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.
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 testSee the failure:
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.
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.
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!
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:resetCaution
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:
-
The SQL queries in
findandfindAllto include the new column:select id, title, description, user_id from item where ...
-
The
createandupdatemethods to handledescription: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:
- Update the client-side Zod validation schema (
itemSchema) to includedescription. - Declare a unique ID for the input using
useId()(e.g.,const descriptionId = useId()). - Add a
<label>and<input>for thedescriptionfield 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:
- Create a new item with a description
- Verify the description appears on the item's detail page
- 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.
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 testThe 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.
You have completed the introductory exercises for StartER! You know how to:
- Create an API endpoint with Express
- Create a React page and connect it to the router
- Connect the frontend to the backend to display dynamic data
- Modify an existing resource across the full stack
- Update tests to reflect your changes
- 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.
AI co-creation
Getting started
Explanations
How-To Guides
Reference
Digging deeper