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
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:
cd t3-gallery
To make sure everything is working, start the development server:
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:
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):
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:
pnpm add @clerk/nextjs
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;
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:
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
:
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:
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
:
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:
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:
pnpm add zustand
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 }),
}));
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> );
};
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
Initialize Prisma with a PostgreSQL database:
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:
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:
pnpm prisma migrate dev --name init
Next, instantiate the Prisma Client in src/server/db/client.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
:
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:
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:
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:
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:
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:
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:
pnpm add @sentry/nextjs
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);
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;
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:
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:
pnpm add posthog-js
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;
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:
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!
Discover more from Coursity
Subscribe to get the latest posts sent to your email.