DEV Community

Cover image for Building a Production-Ready Web App with T3 Stack
Fabrikapp
Fabrikapp

Posted on

Building a Production-Ready Web App with T3 Stack

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:

pnpm create t3-app@latest
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

cd t3-gallery
Enter fullscreen mode Exit fullscreen mode

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

pnpm dev
Enter fullscreen mode Exit fullscreen mode

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:

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;
Enter fullscreen mode Exit fullscreen mode

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):

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
Enter fullscreen mode Exit fullscreen mode

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:

pnpm add @clerk/nextjs
Enter fullscreen mode Exit fullscreen mode

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

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;
Enter fullscreen mode Exit fullscreen mode

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

NEXT_PUBLIC_CLERK_FRONTEND_API=your_api_key_here
Enter fullscreen mode Exit fullscreen mode

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

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>  
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

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;
Enter fullscreen mode Exit fullscreen mode

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:

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);
Enter fullscreen mode Exit fullscreen mode

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:

pnpm add zustand
Enter fullscreen mode Exit fullscreen mode

Create a new file src/stores/useGalleryStore.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 }),
}));
Enter fullscreen mode Exit fullscreen mode

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:

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>  
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

pnpm add @tanstack/react-query @prisma/client
Enter fullscreen mode Exit fullscreen mode

Initialize Prisma with a PostgreSQL database:

pnpm prisma init
Enter fullscreen mode Exit fullscreen mode

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:

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
}
Enter fullscreen mode Exit fullscreen mode

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:

pnpm prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

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

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

export const prisma = new PrismaClient();
Enter fullscreen mode Exit fullscreen mode

Now we can define our React Query hooks for loading and mutating data. Create a new file src/utils/useImages.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]);
      },  
    }
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

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

  return {
    props: {
      images,
    },
    revalidate: 60, // Regenerate the page every 60 seconds
  };
};
Enter fullscreen mode Exit fullscreen mode

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:

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

import Image from "next/image";

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

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:

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

pnpm add @sentry/nextjs
Enter fullscreen mode Exit fullscreen mode

Configure Sentry in your next.config.js file:

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

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

const sentryWebpackPluginOptions = {
  silent: true,
};

module.exports = withSentryConfig(moduleExports, sentryWebpackPluginOptions);
Enter fullscreen mode Exit fullscreen mode

Initialize Sentry in src/pages/_app.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;
Enter fullscreen mode Exit fullscreen mode

Add your Sentry DSN to .env.local:

SENTRY_DSN=your_dsn_here
Enter fullscreen mode Exit fullscreen mode

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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:

pnpm add posthog-js
Enter fullscreen mode Exit fullscreen mode

Initialize PostHog in src/pages/_app.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;
Enter fullscreen mode Exit fullscreen mode

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

NEXT_PUBLIC_POSTHOG_KEY=your_api_key_here
Enter fullscreen mode Exit fullscreen mode

Now you can capture events anywhere in your app:

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)