Skip to content

Day Two

Frontend Scaffolding, Global Layout & Profile Page

Section titled “Frontend Scaffolding, Global Layout & Profile Page”

Welcome to Day Two! Today we shift focus to the frontend. We will scaffold a modern React application using React Router v7, which brings the power and conventions of the Remix framework into the core library. We’ll set up everything from the build tools and code quality guardrails to the core application shell. Then, we will create a global “app shell” with our main navigation, and build the primary user view: a /profile page that displays a grid of posts.


  • Create the Project Directory and Git Repo

    Make sure you do that in a separate parent folder from your backend project:

    Terminal window
    # Make sure you're not in your backend project directory,
    # when you run these commands
    mkdir insta-clone-react-frontend
    cd insta-clone-react-frontend
    git init
  • Bootstrap the React Router Project This command scaffolds a new project with Vite, TypeScript, and Tailwind CSS pre-configured. You can simply press Enter through the prompts to accept the defaults.

    Terminal window
    npx create-react-router@latest .
  • Install Additional Dependencies with NPM

    Terminal window
    npm install axios zod zustand amparo-fastify

A professional project needs professional tooling.

  • Install Dev Dependencies with NPM We need to add plugins for ESLint, testing libraries, and the @react-router/fs-routes package for our routing.

    Terminal window
    npm install --save-dev prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react-hooks eslint-plugin-react-refresh @react-router/fs-routes
  • Configure Prettier: Create a .prettierrc file in the project root.

    Click to show the code for .prettierrc
    .prettierrc
    {
    "printWidth": 80,
    "semi": true,
    "singleQuote": true,
    "tabWidth": 2,
    "trailingComma": "es5"
    }
  • Configure ESLint: Replace eslint.config.mjs. We will add a rule to disable the “fast-refresh” warning, as it conflicts with React Router’s requirement to export action and loader functions from route files.

    Click to show the code for eslint.config.mjs
    eslint.config.mjs
    import tsPlugin from "@typescript-eslint/eslint-plugin";
    import tsParser from "@typescript-eslint/parser";
    import reactHooks from "eslint-plugin-react-hooks";
    import reactRefresh from "eslint-plugin-react-refresh";
    export default [
    {
    files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
    plugins: {
    "@typescript-eslint": tsPlugin,
    "react-hooks": reactHooks,
    "react-refresh": reactRefresh,
    },
    languageOptions: {
    parser: tsParser,
    parserOptions: {
    ecmaFeatures: { jsx: true },
    },
    globals: { browser: true, es2020: true, node: true },
    },
    rules: {
    ...tsPlugin.configs["eslint-recommended"].rules,
    ...tsPlugin.configs["recommended"].rules,
    ...reactHooks.configs.recommended.rules,
    // This rule is incompatible with the React Router / Remix convention
    // of exporting actions and loaders from route files.
    "react-refresh/only-export-components": "off",
    },
    },
    ];

3. React Router v7 Framework Configuration

Section titled “3. React Router v7 Framework Configuration”
  • Enable Flat File-System Routes Edit app/routes.ts.

    Click to show the code for routes.ts
    app/routes.ts
    import { type RouteConfig } from "@react-router/dev/routes";
    import { flatRoutes } from "@react-router/fs-routes";
    export default flatRoutes() satisfies RouteConfig;

4. Application Shell and Layout (app/root.tsx)

Section titled “4. Application Shell and Layout (app/root.tsx)”
  • Modify app/root.tsx This file defines the root HTML document (Layout), the main visual structure (App), and a top-level ErrorBoundary.

    Click to show the code for root.tsx
    app/root.tsx
    import {
    isRouteErrorResponse,
    Links,
    Meta,
    Outlet,
    Scripts,
    ScrollRestoration,
    useRouteError,
    } from "react-router";
    import stylesheet from "./app.css?url";
    export function links() {
    return [{ rel: "stylesheet", href: stylesheet }];
    }
    export function Layout({ children }: { children: React.ReactNode }) {
    return (
    <html lang='en' className='min-h-screen'>
    <head>
    <meta charSet='utf-8' />
    <meta name='viewport' content='width=device-width, initial-scale=1' />
    <Meta />
    <Links />
    </head>
    <body className='min-h-screen bg-gray-50 text-gray-800'>
    {children}
    <ScrollRestoration />
    <Scripts />
    </body>
    </html>
    );
    }
    export default function App() {
    return (
    <>
    <header className='sticky top-0 z-50 w-full border-b bg-white'>
    <nav className='container mx-auto px-4 py-3'>
    <h1 className='text-xl font-bold'>Instagram</h1>
    </nav>
    </header>
    <main className='container mx-auto p-4'>
    <Outlet />
    </main>
    <footer className='py-4 text-center text-sm text-gray-500'>
    <p>&copy; 2025 Webeet</p>
    </footer>
    </>
    );
    }
    export function ErrorBoundary() {
    const error = useRouteError();
    return (
    <Layout>
    <div className='container mx-auto p-4 text-center'>
    <h1 className='text-2xl font-bold'>Oops!</h1>
    <p>Sorry, an unexpected error has occurred.</p>
    {isRouteErrorResponse(error) && (
    <p className='text-red-500'>
    <i>
    {error.status} {error.statusText}
    </i>
    </p>
    )}
    </div>
    </Layout>
    );
    }
  • Verify in the Browser

    Terminal window
    npm run dev

    Navigate to `http://localhost:5173/

  • Commit Your Work

    Terminal window
    git add -A
    git commit -m "feat(*): create project scaffolding and main layout example"

Now, we’ll refactor our frontend to use a more powerful and scalable architecture. We will create a global “app shell” with our main navigation, and then build the primary user view: a /profile page that displays a grid of posts. This structure is much closer to a real-world application. We’ll also extend our backend with a GET /posts endpoint and implement the reels module using TDD.


Backend Milestones (Repo 1: insta-clone-fastify-backend) ✅

Section titled “Backend Milestones (Repo 1: insta-clone-fastify-backend) ✅”

Our first task is to add a missing endpoint for posts, and we will do it using TDD principles to guide our refactoring.

We’ll now build the reels module, starting with a failing test for the GET /reels/grid endpoint.

  • Create the reels test file

    Terminal window
    mkdir -p src/modules/reels
    touch src/modules/reels/reels.test.ts
  • Write the integration test This test describes what we want: an endpoint that returns an array of reels and a 200 OK status.

    Click to show the code for reels.test.ts
    src/modules/reels/reels.test.ts
    import Fastify from "fastify";
    import { reelsRoutes } from "./reels.routes";
    describe("GET /reels/grid", () => {
    it("should return a list of reels with a 200 status code", async () => {
    const app = Fastify();
    const mockReels = [
    {
    id: 1,
    video_url:
    "[http://example.com/video1.mp4](http://example.com/video1.mp4)",
    thumbnail_url:
    "[http://example.com/thumb1.png](http://example.com/thumb1.png)",
    caption: "Reel 1",
    views: 100,
    },
    {
    id: 2,
    video_url:
    "[http://example.com/video2.mp4](http://example.com/video2.mp4)",
    thumbnail_url:
    "[http://example.com/thumb2.png](http://example.com/thumb2.png)",
    caption: "Reel 2",
    views: 200,
    },
    ];
    // To satisfy TypeScript, our mock must match the full shape of the
    // 'transactions' dependency, including all methods on 'posts'.
    app.decorate("transactions", {
    posts: {
    create: jest.fn(),
    getAll: jest.fn(),
    getById: jest.fn(),
    },
    reels: {
    getAll: jest.fn().mockReturnValue(mockReels),
    },
    });
    app.register(reelsRoutes);
    const response = await app.inject({
    method: "GET",
    url: "/reels/grid",
    });
    expect(response.statusCode).toBe(200);
    expect(JSON.parse(response.payload)).toEqual(mockReels);
    });
    });
  • Run the test and watch it fail

    Terminal window
    npm test

    It will fail because reels.routes.ts doesn’t exist. This is our Red light. Perfect.

Now, we write the minimum amount of code required to make our new test pass. This includes creating the types, updating the database, implementing the service and routes, and loading the module in the server.

  • Create and implement reels.types.ts

    Terminal window
    touch src/modules/reels/reels.types.ts
  • Update database.plugin.ts and database.transactions.ts

  • Create and Implement reels.service.ts and reels.routes.ts

    Terminal window
    touch src/modules/reels/reels.service.ts src/modules/reels/reels.routes.ts
  • Load the Reels Module in server.ts

  • Run the test again

    Terminal window
    npm test

    You will see the test suite from posts.test.ts failing to run. If you read closely, you will see jest telling you: ‘Property ‘reels’ is missing in type…’. That is because we have changed our transactions object when we added the reels object. TypeScript expects the transactions object to match a single shape across our Fastify application, even inside our test file. So, you will have to add a reels property to the transactions object wherever you mock it inside your tests.

    Then, with all the pieces in place, the reels.test.ts suite should now pass. This is our Green light.

Our test is green, and the feature is “functionally” complete - it gets data from the database to the browser. But it doesn’t look or feel like Instagram yet. This is the Refactor phase.

The goal as we go is to take the working-but-basic implementation and polish it into a pixel-perfect clone of the real Instagram..

TDD gives us the confidence to make these UI and functionality changes, knowing that our tests will immediately tell us if we’ve broken the core data-fetching logic.

This is where you show what you’ve learned and use the patterns we have been building to create new features and polish!


Frontend Milestones (Repo 2: insta-clone-react-frontend) ✅

Section titled “Frontend Milestones (Repo 2: insta-clone-react-frontend) ✅”
  • Create an Axios instance for API calls

    Terminal window
    mkdir -p app/services
    touch app/services/api.ts
    Click to show the code for api.ts
    app/services/api.ts
    import axios from "axios";
    // We define the base URL of our backend API.
    const api = axios.create({
    baseURL: "http://localhost:3000", // Your Fastify backend address
    });
    export { api };
  • Define the Post Schema with Zod

    Terminal window
    mkdir -p app/schemas
    touch app/schemas/post.schema.ts
    Click to show the code for post.schema.ts
    app/schemas/post.schema.ts
    import { z } from "zod";
    // First, we declare a zod schema
    const postSchema = z.object({
    id: z.number(),
    img_url: z.string().url(),
    caption: z.string().nullable(),
    created_at: z.string(),
    });
    const postsSchema = z.array(postSchema);
    // Then, we infer the TypeScript type from the Zod schema.
    type Post = z.infer<typeof postSchema>;
    export { postSchema, postsSchema };
    export type { Post };
  • Create the Reels Schema (app/schemas/reel.schema.ts)

    Terminal window
    touch app/schemas/reel.schema.ts
    Click to show the code for reel.schema.ts
    app/schemas/reel.schema.ts
    import { z } from "zod";
    const reelSchema = z.object({
    id: z.number(),
    video_url: z.string().url(),
    thumbnail_url: z.string().url(),
    caption: z.string().optional(),
    views: z.number().int().min(0),
    created_at: z.string(),
    });
    const reelsSchema = z.array(reelSchema);
    type Reel = z.infer<typeof reelSchema>;
    export { reelSchema, reelsSchema };
    export type { Reel };
  • Create Reusable UI Components

    Terminal window
    mkdir -p app/components
    touch app/components/Header.tsx app/components/BottomNav.tsx app/components/PostCard.tsx app/components/ReelGridItem.tsx
  • Header.tsx

    Click to show the code for Header.tsx
    app/components/Header.tsx
    export function Header() {
    return (
    <header className='sticky top-0 z-50 w-full border-b bg-white'>
    <nav className='container mx-auto flex items-center justify-between px-4 py-3'>
    <h1 className='text-xl font-bold'>Instagram</h1>
    <div className='text-xl'>❤️</div>
    </nav>
    </header>
    );
    }
  • BottomNav.tsx with Links

    Click to show the code for BottomNav.tsx
    app/components/BottomNav.tsx
    import { Link } from "react-router";
    export function BottomNav() {
    return (
    <footer className='fixed bottom-0 left-0 z-50 w-full h-16 bg-white border-t'>
    <div className='grid h-full max-w-lg grid-cols-5 mx-auto font-medium'>
    <Link
    to='/home'
    className='inline-flex flex-col items-center justify-center px-5'
    >
    🏠
    </Link>
    <div className='inline-flex flex-col items-center justify-center px-5'>
    🔍
    </div>
    <Link
    to='/home'
    className='inline-flex flex-col items-center justify-center px-5'
    >
    </Link>
    <Link
    to='/'
    className='inline-flex flex-col items-center justify-center px-5'
    >
    Reels
    </Link>
    <Link
    to='/profile'
    className='inline-flex flex-col items-center justify-center px-5'
    >
    👤
    </Link>
    </div>
    </footer>
    );
    }
  • PostCard.tsx

    Click to show the code for PostCard.tsx
    app/components/PostCard.tsx
    import type { Post } from "~/schemas/post.schema";
    export function PostCard({ post }: { post: Post }) {
    return (
    <div className='w-full max-w-lg mx-auto rounded-lg overflow-hidden border bg-white mb-6'>
    <div className='p-4'>
    <p className='font-bold'>webeet_user</p>
    </div>
    <img
    src={post.img_url}
    alt={post.caption || "Instagram post"}
    className='w-full h-auto aspect-square object-cover'
    />
    <div className='p-4'>
    <p>
    <span className='font-bold mr-2'>webeet_user</span>
    {post.caption}
    </p>
    </div>
    </div>
    );
    }
  • ReelGridItem.tsx

    Click to show the code for ReelGridItem.tsx
    app/components/ReelGridItem.tsx
    import type { Reel } from "~/schemas/reel.schema";
    export function ReelGridItem({ reel }: { reel: Reel }) {
    return (
    <div className='relative w-full aspect-[9/16] overflow-hidden bg-gray-200'>
    <img
    src={reel.thumbnail_url}
    alt={reel.caption || "Reel thumbnail"}
    className='w-full h-full object-cover'
    />
    <div className='absolute bottom-2 left-2 text-white text-sm font-semibold flex items-center'>
    ▶️ {reel.views}
    </div>
    </div>
    );
    }

Now we will modify our root layout to include the Header and BottomNav, making them appear on every page.

  • Update app/root.tsx

    Click to show the code for root.tsx
    app/root.tsx (Updated)
    import {
    isRouteErrorResponse,
    Links,
    Meta,
    Outlet,
    Scripts,
    ScrollRestoration,
    useRouteError,
    } from "react-router";
    import stylesheet from "./app.css?url";
    import { Header } from "./components/Header";
    import { BottomNav } from "./components/BottomNav";
    export function links() {
    return [{ rel: "stylesheet", href: stylesheet }];
    }
    export function Layout({ children }: { children: React.ReactNode }) {
    return (
    <html lang='en' className='min-h-screen'>
    <head>
    <meta charSet='utf-8' />
    <meta name='viewport' content='width=device-width, initial-scale=1' />
    <Meta />
    <Links />
    </head>
    <body className='min-h-screen bg-gray-50 text-gray-800'>
    {children}
    <ScrollRestoration />
    <Scripts />
    </body>
    </html>
    );
    }
    export default function App() {
    return (
    <>
    <Header />
    <main className='container mx-auto p-4'>
    <Outlet />
    </main>
    <BottomNav />
    </>
    );
    }
    export function ErrorBoundary() {
    const error = useRouteError();
    return (
    <Layout>
    <div className='container mx-auto p-4 text-center'>
    <h1 className='text-2xl font-bold'>Oops!</h1>
    <p>Sorry, an unexpected error has occurred.</p>
    {isRouteErrorResponse(error) && (
    <p className='text-red-500'>
    <i>
    {error.status} {error.statusText}
    </i>
    </p>
    )}
    </div>
    </Layout>
    );
    }

5. Creating the Profile Page with Nested Routes

Section titled “5. Creating the Profile Page with Nested Routes”

Finally, we’ll build the profile section, which consists of a layout route and a child route for our posts grid.

  • Create an Index Route to Redirect Users

    Terminal window
    touch app/routes/_index.tsx
    Click to show the code for _index.tsx
    app/routes/_index.tsx
    import { redirect } from "react-router";
    export async function loader() {
    return redirect("/profile/posts/grid");
    }
  • Create the Profile Layout Route

    Terminal window
    touch app/routes/profile.tsx
    Click to show the code for profile.tsx
    app/routes/profile.tsx
    import { NavLink, Outlet } from "react-router";
    export default function ProfileLayout() {
    const activeLinkStyle = {
    borderBottom: "2px solid black",
    fontWeight: "bold",
    };
    return (
    <div>
    <div className='flex justify-center items-center border-b mb-4'>
    <NavLink
    to='/profile/posts/grid'
    className='flex-1 text-center p-4'
    style={({ isActive }) => (isActive ? activeLinkStyle : undefined)}
    >
    Posts
    </NavLink>
    <NavLink
    to='/profile/reels/grid'
    className='flex-1 text-center p-4'
    style={({ isActive }) => (isActive ? activeLinkStyle : undefined)}
    >
    Reels
    </NavLink>
    </div>
    <main>
    <Outlet />
    </main>
    </div>
    );
    }
  • Create the Posts Grid Route

    Terminal window
    touch app/routes/profile.posts.grid.tsx
    Click to show the code for profile.posts.grid.tsx
    app/routes/profile.posts.grid.tsx
    import { useLoaderData } from "react-router";
    import { api } from "~/services/api";
    import { postsSchema, type Post } from "~/schemas/post.schema";
    import { PostCard } from "~/components/PostCard";
    export async function loader() {
    try {
    const response = await api.get("/posts");
    return postsSchema.parse(response.data);
    } catch (error) {
    console.error("Failed to load posts:", error);
    throw new Response("Could not load posts.", { status: 500 });
    }
    }
    export default function PostsGrid() {
    const posts = useLoaderData() as Post[];
    return (
    <div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
    {posts.map((post) => (
    <PostCard key={post.id} post={post} />
    ))}
    </div>
    );
    }
  • Create the Reels Grid Route (app/routes/profile.reels.grid.tsx)

    Terminal window
    touch app/routes/profile.reels.grid.tsx
    Click to show the code for profile.reels.grid.tsx
    app/routes/profile.reels.grid.tsx
    import { useLoaderData } from "react-router";
    import { api } from "~/services/api";
    import { reelsSchema, type Reel } from "~/schemas/reel.schema";
    import { ReelGridItem } from "~/components/ReelGridItem";
    export async function loader() {
    try {
    const response = await api.get("/reels/grid");
    return reelsSchema.parse(response.data);
    } catch (error) {
    console.error("Failed to load reels:", error);
    throw new Response("Could not load reels.", { status: 500 });
    }
    }
    export default function ReelsGrid() {
    const reels = useLoaderData() as Reel[];
    return (
    <div className='grid grid-cols-2 md:grid-cols-4 gap-1'>
    {reels.map((reel) => (
    <ReelGridItem key={reel.id} reel={reel} />
    ))}
    </div>
    );
    }

  1. Restart both your backend and frontend servers to apply all changes.
  2. Navigate to http://localhost:5173/. You should be redirected to the posts grid at /profile/posts/grid.
  3. Click the “Reels” tab in the sub-navigation.
  4. The URL should change to /profile/reels/grid, and you should now see the grid of reel thumbnails fetched from your backend.

  • Commit and Push Your Work to main Branch

    Terminal window
    git add -A
    git commit -m "feat(*): <describe-what-you-did>"
  • Follow the Same Git Workflow as on Day 1 - But For Your Frontend

    When you’re done, you should have:

    1. Three branches: main, <your-name>, _<your-name>/day-2
    2. Two remotes: webeet and portfolio
    3. Pushed your work to your daily branch for review

Look at how quickly you added a new feature! This is the direct benefit of the architecture you built.

  1. Leveraging Existing Layouts: You didn’t need to create any new navigation or layout components. You simply created a new route file (profile.reels.grid.tsx), and it was automatically rendered within the correct context by the parent profile.tsx layout. This is the power of nested routing.
  2. A Predictable, Repeatable Pattern: The process for adding a new data-driven page is now crystal clear:
    1. Create the backend module (types, db, service, routes).
    2. Create a Zod schema on the frontend.
    3. Build the small, reusable UI components.
    4. Assemble them in a new route file with a loader. This pattern makes development fast and predictable.
  3. The Importance of Seeding: Manually creating data via curl is fine for one-off tests, but for developing a UI, having a reliable set of seed data that loads automatically saves a tremendous amount of time and effort.
  4. Continuous Improvement: The “Housekeeping” task at the beginning shows that development is an iterative process. We often need to go back and improve or fix previous work as new requirements come to light.

You’ve successfully built out the core frontend structure, connected it to your backend, and implemented your first GET endpoints with TDD!