Building a Production-Ready Web App with T3 Stack
Published: 2024-04-19 22:32:22


## Building a Production-Ready Web App with React: A Comprehensive Tutorial

In this in-depth tutorial, we will walk through the process of building a fully-featured, production-ready web application using modern React development practices and libraries. By the end of this course, you will have a strong understanding of how to architect and implement a scalable, performant, and maintainable React application.

We will cover a wide range of topics, including:

- Setting up a development environment with **TypeScript, Tailwind CSS, and the T3 stack** 
- Implementing user authentication and authorization using **Clerk**
- Managing application state with libraries like **Zustand**
- Handling data fetching and mutations with React Query and **Prisma**
- Optimizing performance with **Next.js** features like server-side rendering and incremental static regeneration
- Deploying the application to **Vercel**
- Monitoring errors with **Sentry**
- Collecting analytics with **PostHog**

Throughout the tutorial, we will work on building a real-world application - an image gallery that allows users to upload, view, and manage their photos. We'll implement features incrementally, focusing on best practices and common pitfalls to avoid.

## Prerequisites 

This is an intermediate to advanced level tutorial. To get the most out of it, you should have a solid foundation in HTML, CSS, JavaScript, and React fundamentals. Familiarity with TypeScript and Next.js is helpful but not required. 

## Project Setup

Let's get started by scaffolding a new Next.js project using the create-t3-app tool. This will set us up with a bunch of useful libraries and conventions out of the box.

Open your terminal and run:

```sh
pnpm create t3-app@latest
```

You'll be prompted to choose a set of options for your project:

```
? What will your project be called? -> t3-gallery
? Will you be using TypeScript or JavaScript? -> TypeScript
? Which packages would you like to enable? -> Tailwind CSS
? Initialize a new git repository? -> Yes
? Would you like us to run 'npm install'? -> Yes, use pnpm
```

After the installation finishes, change into the new project directory:

```sh
cd t3-gallery
```

To make sure everything is working, start the development server:

```sh
pnpm dev
```

Then open http://localhost:3000 in your browser. You should see the default Next.js starter page.

## Cleaning Up the Starter Code

The create-t3-app template comes with some placeholder content that we won't need for our image gallery app. Let's clean that up.

Replace the contents of `src/pages/index.tsx` with:

```tsx
const Home = () => {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center">
      <h1 className="text-4xl font-bold">Welcome to the T3 Gallery!</h1>
    </main>
  );
};

export default Home;
```

This gives us a simple homepage with a title. The `className` attributes are using Tailwind utility classes to style the elements.

Delete the `src/pages/api` directory since we won't be using API routes in this project.

Also delete the `src/styles/globals.css` file and remove its import from `src/pages/_app.tsx`, since we'll be styling everything with Tailwind.

## Deploying to Vercel

Before we start building out the features of our app, let's deploy it to Vercel. This will let us easily share our progress and monitor the application in production.

First, push your code to a new GitHub repository (you can skip this if you initialized the repo through GitHub when creating the project):

```sh
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin https://github.com/yourusername/t3-gallery.git
git push -u origin main
```

Now go to https://vercel.com, sign up for an account, and click "New Project". Connect your GitHub account and give Vercel permission to access your repositories. 

Select your t3-gallery repo and click "Import". On the next screen, leave all the default settings and click "Deploy". In a minute or two, your app will be live at a URL like https://t3-gallery.vercel.app.

Whenever you push changes to the main branch of your GitHub repo, Vercel will automatically redeploy your application. You can also preview changes from pull requests before merging.

## Let's get coding !

In this section, we set up the initial codebase for our image gallery application and deployed it to production on Vercel. 

We used the create-t3-app template to quickly bootstrap a Next.js project with TypeScript and Tailwind CSS preconfigured. Then we cleaned up some of the starter code to prepare for implementing our own features.

By deploying to Vercel from the beginning, we can easily share our progress, get feedback, and monitor the app's real-world performance as we continue to build it out.

In the next sections, we'll start adding core functionality like user authentication, image uploads, and data persistence. You'll see how to integrate various libraries and services to efficiently develop production-ready features.

The tutorial continues on from here to cover the core features outlined in the introduction. I can continue generating the content if you'd like. Let me know if you have any other specific instructions or topics you want me to focus on in the subsequent sections. I'm aiming to provide comprehensive, well-structured, and example-driven explanations suitable for an online programming course.

## User Authentication with Clerk

Let's implement user registration and login using Clerk. Clerk provides a complete authentication solution with a prebuilt, customizable UI and secure backend logic. It supports various authentication methods like email/password, OAuth, and magic links.

First, sign up for a free account at https://clerk.com. Create a new application and make note of your "Frontend API" key.

Install the Clerk React SDK:

```sh
pnpm add @clerk/nextjs
```

Wrap your `src/pages/_app.tsx` component with the `ClerkProvider`:

```tsx
import { ClerkProvider } from '@clerk/nextjs';

function MyApp({ Component, pageProps }) {
  return (
    <ClerkProvider frontendApi={process.env.NEXT_PUBLIC_CLERK_FRONTEND_API}>
      <Component {...pageProps} />
    </ClerkProvider>
  );
}

export default MyApp;
```

Add your Clerk frontend API key to `.env.local`:

```
NEXT_PUBLIC_CLERK_FRONTEND_API=your_api_key_here
```

Now add the Clerk `<SignIn />` and `<SignUp />` components to your homepage:

```tsx
import { SignIn, SignUp } from "@clerk/nextjs";

const Home = () => {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center">
      <h1 className="text-4xl font-bold">Welcome to the T3 Gallery!</h1>
      <div className="mt-8">
        <SignIn path="/sign-in" routing="path" signUpUrl="/sign-up" />
      </div>
      <div className="mt-8">
        <SignUp path="/sign-up" routing="path" signInUrl="/sign-in" />
      </div>
    </main>
  );
};
```

This displays the Clerk sign in and sign up forms on our homepage. The `path` and `routing` props specify that we want to use path-based routing for these pages. The `signUpUrl` and `signInUrl` props create links between the forms.

Start your development server and test out the login and registration flow. You should be able to create an account, log in, and log out.

To show different content based on the user's authentication status, use the `useUser` hook from `@clerk/nextjs`:

```tsx
import { useUser } from "@clerk/nextjs";

const Home = () => {
  const { isLoaded, isSignedIn, user } = useUser();

  if (!isLoaded) {
    return null;
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center">
      {isSignedIn ? (
        <>
          <h1 className="text-4xl font-bold">Welcome {user.firstName}!</h1>
          <button onClick={() => signOut()} className="mt-8 rounded-md bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-600">
            Sign Out
          </button>
        </>
      ) : (
        <>
          <h1 className="text-4xl font-bold">Welcome to the T3 Gallery!</h1>
          <div className="mt-8">
            <SignIn path="/sign-in" routing="path" signUpUrl="/sign-up" />
          </div>
          <div className="mt-8">  
            <SignUp path="/sign-up" routing="path" signInUrl="/sign-in" />
          </div>
        </>
      )}
    </main>
  );
};
```

Here, we conditionally render a welcome message and sign out button if the user is signed in, or the sign in and sign up forms if they're signed out.

Clerk also provides a `<UserButton />` component that displays the user's profile image and a dropdown menu with account management options:

```tsx
import { UserButton } from "@clerk/nextjs";

const Home = () => {
  const { isLoaded, isSignedIn } = useUser();

  if (!isLoaded) {
    return null;
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center">
      {isSignedIn ? (
        <>
          <UserButton />
          {/* ... */}
        </>
      ) : (
        {/* ... */}
      )}
    </main>  
  );
};
```

With just a few lines of code, we've added complete user authentication to our app! In the next section, we'll look at how to protect certain routes and use the user's information to personalize their experience.

## Securing Pages and API Routes

By default, all pages in a Next.js app are publicly accessible. To limit access to authenticated users, we can use Clerk's `withServerSideAuth` and `withAuthMiddleware` helpers.

Create a new file `src/pages/gallery/index.tsx`:

```tsx
import { withServerSideAuth } from "@clerk/nextjs/ssr";

export const getServerSideProps = withServerSideAuth(async ({ req }) => {
  const { userId } = req.auth;
  
  // Load the user's images based on their userId
  const images = await db.image.findMany({
    where: { userId },
  });

  return { props: { images } };
});

const GalleryPage = ({ images }) => {
  return (
    <div>
      <h1>My Gallery</h1>
      {/* Display the user's images */}
    </div>
  );
};

export default GalleryPage;
```

The `withServerSideAuth` helper verifies the user's authentication status and attaches their `userId` to the request object. If the user is not signed in, they will automatically be redirected to the sign in page.

We can access the `userId` in `getServerSideProps` to fetch data specific to that user, like their uploaded images in this example.

For API routes, use the `withMiddlewareAuthRequired` helper:

```ts
import { withMiddlewareAuthRequired } from "@clerk/nextjs/server";

const handler = async (req, res) => {
  const { userId } = req.auth;

  switch (req.method) {
    case "POST":
      // Create a new image for this user
      break;
    case "PUT":
      // Update one of the user's images
      break;  
    case "DELETE":
      // Delete one of the user's images
      break;
    default:
      res.status(405).json({ message: "Method not allowed" });
  }
};

export default withMiddlewareAuthRequired(handler);
```

The `withMiddlewareAuthRequired` helper attaches the `userId` to the `req.auth` object, allowing you to perform user-specific actions in your API route handlers. If the request is not authenticated, it will return a 401 Unauthorized response.

By using these helpers throughout our app, we can easily implement granular, user-based access control to pages, API routes, and data.

## Managing Application State 

As our app grows in complexity, we'll need a way to manage state that's shared across multiple components. We could use React's built-in state and prop drilling, but that quickly becomes cumbersome and hard to maintain.

Instead, we'll use Zustand, a lightweight state management library. It allows us to create a centralized store and update it from anywhere in our component tree.

Install Zustand:

```sh
pnpm add zustand
```

Create a new file `src/stores/useGalleryStore.ts`:

```ts
import { create } from "zustand";

type Image = {
  id: string;
  url: string;
  // ... other fields
};

type GalleryState = {
  images: Image[];
  selectedImageId: string | null;
  setImages: (images: Image[]) => void;
  setSelectedImageId: (id: string | null) => void;
};

export const useGalleryStore = create<GalleryState>((set) => ({
  images: [],
  selectedImageId: null,
  setImages: (images) => set({ images }),
  setSelectedImageId: (id) => set({ selectedImageId: id }),
}));
```

Here we define the shape of our gallery's state with the `GalleryState` type. It includes an array of `images`, the `selectedImageId` for the currently focused image, and setter functions to update those values.

The `useGalleryStore` hook is created by passing an initial state object to Zustand's `create` function.

Now we can import and use this hook in any component:

```tsx
import { useGalleryStore } from "../stores/useGalleryStore";

const GalleryPage = ({ initialImages }) => {
  const images = useGalleryStore((state) => state.images);
  const setImages = useGalleryStore((state) => state.setImages);
  
  useEffect(() => {
    setImages(initialImages);
  }, [initialImages, setImages]);

  return (
    <div>
      {images.map((image) => (
        <img key={image.id} src={image.url} alt={image.name} />
      ))}
    </div>  
  );
};
```

We use the `useGalleryStore` hook to access the `images` array and `setImages` function from our global state. The component receives `initialImages` from `getServerSideProps`, which we use to populate the store on mount via `useEffect`.

Zustand automatically re-renders components that use the hook whenever the state values they depend on are updated. This lets us efficiently sync the UI with our global state.

Some other cool features of Zustand:

- Computed state values with memoization
- Transient updates (state changes that don't trigger re-renders)
- Async actions with Thunk-like syntax
- Persist state to local/session storage
- TypeScript support
- Tiny bundle size (< 1kb gzipped)

## Data Fetching and Mutations with React Query

For data that's loaded from or saved to an external API, we'll use React Query. It provides a powerful set of hooks for fetching, caching, synchronizing, and updating server state in our React app.

Install React Query and the Prisma Client:

```sh
pnpm add @tanstack/react-query @prisma/client
```

Initialize Prisma with a PostgreSQL database:

```sh
pnpm prisma init
```

This command creates a new Prisma schema file and `.env` for your database connection URL.

Update the Prisma schema in `prisma/schema.prisma` to define your app's data models. For our image gallery app, it might look something like:

```prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider        = "prisma-client-js" 
  previewFeatures = ["referentialIntegrity"]
}

model User {
  id       String    @id @default(cuid())
  email    String    @unique
  name     String?
  images   Image[]
}

model Image {
  id       String  @id @default(cuid())
  url      String
  creator  User    @relation(fields: [userId], references: [id])
  userId   String
}
```

Here we define two models: `User` and `Image`. A user can have multiple images, and each image is associated with a user via the `userId` foreign key and `@relation` attribute.

After defining your schema, create the database tables with:

```sh
pnpm prisma migrate dev --name init
```

Next, instantiate the Prisma Client in `src/server/db/client.ts`:

```ts
import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient();
```

Now we can define our React Query hooks for loading and mutating data. Create a new file `src/utils/useImages.ts`:

```ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { prisma } from "../server/db/client";

const IMAGES_QUERY_KEY = "images";

export const useImages = (userId: string) => {
  return useQuery([IMAGES_QUERY_KEY, userId], async () => {
    const images = await prisma.image.findMany({
      where: { userId },
    });
    return images;
  });
};

export const useAddImage = () => {
  const queryClient = useQueryClient();

  return useMutation(
    async (newImage: { url: string; userId: string }) => {
      const addedImage = await prisma.image.create({
        data: newImage,
      });
      return addedImage;
    },
    {
      onSuccess: () => {
        queryClient.invalidateQueries([IMAGES_QUERY_KEY]);
      },
    }
  );
};

export const useDeleteImage = () => {
  const queryClient = useQueryClient();
  
  return useMutation(
    async (imageId: string) => {
      await prisma.image.delete({ where: { id: imageId } }); 
    },
    {
      onSuccess: () => {
        queryClient.invalidateQueries([IMAGES_QUERY_KEY]);
      },  
    }
  );
};
```

Here we define three hooks:

- `useImages` - A query hook that loads a user's images from the database
- `useAddImage` - A mutation hook for adding a new image
- `useDeleteImage` - A mutation hook for deleting an image

The query hook specifies a unique key that identifies the query (`IMAGES_QUERY_KEY` + `userId`). It uses Prisma Client to load the images from the database.

The mutation hooks define two async functions: one to make the actual mutation (create/delete an image), and an `onSuccess` callback that invalidates the `IMAGES_QUERY_KEY`. This tells React Query to refetch the images on the next render, keeping the cache in sync with our database.

To use these hooks in a component:

```tsx
const GalleryPage = () => {
  const { userId } = useUser();
  const { data: images, isLoading } = useImages(userId);
  const addImage = useAddImage();
  const deleteImage = useDeleteImage();

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      {images.map((image) => (
        <div key={image.id}>
          <img src={image.url} alt={image.id} />
          <button onClick={() => deleteImage.mutate(image.id)}>Delete</button>
        </div>
      ))}
      <button onClick={() => addImage.mutate({ url: "https://example.com/new-image.jpg", userId })}>
        Add Image
      </button>
    </div>
  );
};
```

Here we use the `useUser` hook from Clerk to get the current user's ID. We pass that into the `useImages` hook to load the user's images. The `isLoading` flag lets us show a loading state while the query is fetching.

We destructure the `addImage` and `deleteImage` mutation functions from their respective hooks. These are used in the button `onClick` handlers to add and delete images.

React Query automatically handles loading/error states, caching, refetching on window focus, and more. It abstracts away all the boilerplate of CRUD-ing server state, letting you focus on your app's business logic.

## Optimizing Performance with Next.js

Next.js offers several features to improve the loading speed and responsiveness of your app out of the box. Let's look at how to leverage them effectively.

### Incremental Static Regeneration (ISR)

ISR allows you to update existing pages by re-rendering them in the background as traffic comes in. Inspired by stale-while-revalidate, this ensures traffic is served static pages quickly, while new pages are being rendered.

To enable ISR, use the `revalidate` option in your `getStaticProps` function:

```tsx
export const getStaticProps: GetStaticProps = async (context) => {
  const images = await prisma.image.findMany();

  return {
    props: {
      images,
    },
    revalidate: 60, // Regenerate the page every 60 seconds
  };
};
```

Now this page will be statically generated at build time, but also regenerated in the background every 60 seconds as traffic comes in. This keeps your page speed fast while ensuring content is never stale for too long.

### Dynamic Imports and Lazy Loading

To reduce your JavaScript bundle size and speed up loading, you can dynamically import components that aren't needed immediately on page load. This is especially useful for large or complex components like modals, charts, or rich texteditors.

Use Next's `dynamic` function to lazily load a component:

```tsx
import dynamic from "next/dynamic";

const ImageModal = dynamic(() => import("../components/ImageModal"));

const GalleryPage = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <div>
      {/* ... */}
      {isModalOpen && <ImageModal onClose={() => setIsModalOpen(false)} />}
    </div>
  );
};
```

The `ImageModal` component will only be loaded when the modal is opened, reducing the initial bundle size.

You can also use `next/image` to automatically optimize images and lazily load them as they enter the viewport:

```tsx
import Image from "next/image";

const GalleryImage = ({ image }) => {
  return (
    <Image
      src={image.url}
      alt={image.id}
      width={500}
      height={500}
      loading="lazy"
    />
  );
};
```

The `loading="lazy"` prop defers loading the image until it's scrolled into view. Next will also automatically resize, optimize, and serve the image in modern formats like WebP.

### Measuring Performance

To identify performance issues and opportunities for optimization, use the React Profiler and Chrome DevTools Performance tab to record and analyze rendering.

Wrap your component tree in a `Profiler` component to measure rendering performance:

```tsx
import { Profiler } from "react";

function onRenderCallback(
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) {
  console.log(`${id} render took ${actualDuration}ms`);
}

const App = () => {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      {/* ... */}
    </Profiler>
  );
};
```

The `onRender` callback logs render durations to the console, helping you spot slow components. You can also use the React DevTools Profiler to visually explore this data.

To go deeper, use the Chrome DevTools Performance tab to record and analyze runtime performance. This lets you see CPU usage, network requests, and more over time.

By leveraging ISR, lazy loading, and performance profiling, you can ensure your Next.js app stays fast and responsive as it grows in complexity.

## Monitoring Errors with Sentry

To track and debug errors in production, we'll integrate Sentry into our app. Sentry is an error tracking platform that captures exceptions, logs, and performance data to help you identify and fix issues.

First, sign up for a free account at https://sentry.io. Create a new project and make note of your DSN (Data Source Name).

Install the Sentry SDK:

```sh
pnpm add @sentry/nextjs
```

Configure Sentry in your `next.config.js` file:

```js
const { withSentryConfig } = require("@sentry/nextjs");

const moduleExports = {
  // Your existing Next.js config
};

const sentryWebpackPluginOptions = {
  silent: true,
};

module.exports = withSentryConfig(moduleExports, sentryWebpackPluginOptions);
```

Initialize Sentry in `src/pages/_app.tsx`:

```tsx
import { init } from "@sentry/nextjs";

init({
  dsn: process.env.SENTRY_DSN,
});

function MyApp({ Component, pageProps }) {
  return (
    <ClerkProvider frontendApi={process.env.NEXT_PUBLIC_CLERK_FRONTEND_API}>
      <Component {...pageProps} />
    </ClerkProvider>
  );
} 

export default MyApp;
```

Add your Sentry DSN to `.env.local`:

```
SENTRY_DSN=your_dsn_here
```

With this setup, Sentry will automatically capture unhandled exceptions in your app. You can also manually capture errors and add context:

```tsx
import * as Sentry from "@sentry/nextjs";

const MyComponent = () => {
  const { user } = useUser();

  function handleClick() {
    try {
      // Do something that might throw an error
    } catch (error) {
      Sentry.captureException(error, {
        extra: {
          userId: user.id,
        },
      });
    }
  }

  return (
    <button onClick={handleClick}>
      Click me
    </button>
  );
};
```

The `captureException` function sends the error to Sentry along with additional context like the current user ID. This makes it easier to reproduce and debug the issue.

Sentry also provides performance monitoring, release tracking, and issue management features to give you better visibility into your app's health and user experience.

By proactively identifying and fixing bugs with Sentry, you can provide a more stable and reliable application to your users.

## Collecting Analytics with PostHog

To understand how users interact with your app, it's crucial to collect analytics events. This lets you track key metrics, identify bottlenecks in your conversion funnels, and make data-driven decisions about what features to build next.

We'll use PostHog, an open-source product analytics platform, to capture and analyze events in our app.

Sign up for a free PostHog account at https://app.posthog.com. Create a new project and make note of your Project API Key.

Install the PostHog client library:

```sh
pnpm add posthog-js
```

Initialize PostHog in `src/pages/_app.tsx`:

```tsx
import posthog from "posthog-js";

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
  api_host: "https://app.posthog.com",
});

function MyApp({ Component, pageProps }) {
  return (
    <ClerkProvider frontendApi={process.env.NEXT_PUBLIC_CLERK_FRONTEND_API}>
      <Component {...pageProps} />
    </ClerkProvider>
  );
}

export default MyApp;
```

Add your PostHog project API key to `.env.local`:

```
NEXT_PUBLIC_POSTHOG_KEY=your_api_key_here
```

Now you can capture events anywhere in your app:

```tsx
const ImageUpload = () => {
  const [isUploading, setIsUploading] = useState(false);
  const addImage = useAddImage();

  async function handleSubmit(event) {
    event.preventDefault();
    setIsUploading(true);
    
    posthog.capture("Image Uploaded", {
      userId: user.id,
    });

    await addImage.mutate(event.target.image.value);
    setIsUploading(false);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="file" name="image" />
      <button type="submit" disabled={isUploading}>
        {isUploading ? "Uploading..." : "Upload"}
      </button>
    </form>
  );
};
```

Here we capture an "Image Uploaded" event with the `posthog.capture` function. We include the user ID as an event property to let us analyze events per user in PostHog.

Some other common events you might want to track:

- Page views
- Sign up / sign in
- Search queries
- Add to cart / checkout
- Feature usage

You can also use the `posthog.identify` function to attach user properties like email or name to the user ID. This lets you segment your analytics based on user attributes.

PostHog provides an insights dashboard where you can explore your event data, create funnels, and set up retention charts. By understanding how users flow through your app, you can optimize your UX and maximize conversion and retention.

PostHog also supports session recording, feature flagging, A/B testing, and self-hosting, making it a powerful and flexible alternative to SaaS analytics platforms.

## Conclusion

In this comprehensive tutorial, we walked through the process of building a production-ready image gallery application with React, TypeScript, Next.js, and the T3 stack.

We covered a wide range of topics, including:

- User authentication with Clerk
- Global state management with Zustand
- Data fetching and mutations with React Query
- Optimizing performance with ISR, lazy loading, and profiling
- Tracking errors with Sentry
- Collecting analytics with PostHog

By leveraging these technologies and following best practices, you can build modern, full-stack React apps efficiently and focus on delivering value to your users.

Some key takeaways:

- Use a pre-built authentication solution like Clerk to save time and ensure security
- Manage global UI state with a lightweight library like Zustand
- Fetch and cache server state with React Query for optimal performance and developer experience
- Lazy load non-critical components and use ISR to speed up pages
- Track errors in production with Sentry to proactively fix bugs
- Collect analytics with PostHog to understand user behavior and make data-driven decisions

I hope this tutorial has given you a solid foundation for building your own production React apps. Remember to keep learning, experiment with new libraries and techniques, and always prioritize user experience and developer productivity.

Happy coding!