Isaac's Blog

Developing a Modern Full-Stack Application: Choosing the Right Tech Stack, Front-end Edition

In the last part of this post, I covered mostly the back-end parts of my development stack.

I was very excited about getting into front-end development, as I don't have any professional experience there, and heard that a lot has changed since I last built a web-app. Back in 2018, React had a slight lead over Angular, with Vue being considered a serious contender. Now, it looks like React has won, and the debate is over which "meta-framework" to use.

The React Meta-Framework debate looks to me as more political rather than technical. I won't get into it here. My preference was to pick the framework with the largest community, which currently is Next.js.

Next.js

I used the cutting edge Next.js features for Server Components for data fetching, and even more cutting edge Server Actions for data mutations. I felt like I was pushing the limits of the framework when calling my repository functions directly from these React components. I can't think of a quicker way to develop a simple web-app.

However, it's easy to accidentally to leak data to the client when using server components. Here's an example of how this can happen.

// page.tsx
async function Page() {
  const session = await getServerSession(authOptions);
  const organizationUsers = listOrganizationUsers({ orgId: session.orgId })

  return <OrganizationTable users={organizationUsers}/>
}
// organization-table.tsx
async function OrganizationTable({ users }: { users: User[]}) {
  const tableColumns = [
    {"key": "firstName", "header": "Employee Name"},
    {"key": "jobTitle", "header": "Title"}
  ]
  return <Table
    data={users.map((user) => ({
      firstName: user.firstName,
      jobTitle: user.jobTitle
    })}
    columns={columns} />
}

The <OrganizationTable/> component is by default a server component, so the User data is on the server, and we are just rendering first name and job title.

What happens if you want to add some interactivity to the table? You might turn OrganizationTable into a client component, by adding "use client" to the top of the file. Suddenly the entirety of users is exposed to the client. Maybe you are okay with this for now, as there's nothing sensitive, but this could easily turn into a leak if an extra field like "phone number" is added to the User model.

It's hard to avoid problems like this when you have such a high level of coupling between the UI and back-end. GraphQL manages to avoid it by making the client explicitly request the fields it needs, or with a typical HTTP client, you are explicitly choosing which fields to deserialize.

This post on Next.js security) is essential reading for anyone using Next.js in production environments. The recommended approach introduces a data layer and returns Data Transfer Objects (DTOs) to components. However, in my opinion, adopting this approach diminishes the development speed advantage offered by the server component fetching approach.

One pattern that I couldn't avoid was prop-drilling. In this toy example, the accounts are pushed two components deeper, but in real use-case it can be much much worse:

async function Page() {
    const session = await getServerSession(authOptions);
    const accounts = await getUserAccounts(session.id)
	return (
	<>
		<DashboardAccounts accounts={accounts}>
		<DashboardTransactions accounts={accounts}>
		<DashboardBalances accounts={accounts}>
	</>)
}

async function getUserAccounts(id: Number) {
  return await db.Account.findMany({
	  where: { userId: id })
})

async function DashboardTransactions({ accounts }:{ acccounts: Accounts }) {
	const transactions = await getTransactions()
	...
	return <TransactionsList accounts={accounts} transactions={transactions}>
}

async function TransactionsList(
 { accounts, transactions }: { accounts: Accounts, transactions:Transactions }) {
	 ...
 }

Next solves this if the data is being fetched from an endpoint using its fetch API, which by default will cache every request in the component tree. Then you can simply run the fetch method deeper in the component tree, in the child components, and return the accounts from cache. However, in the example above, I'm not fetching from an endpoint, but instead querying the DB, so this won't help. I also couldn't use the typical approaches of React Context or a state manager like Zustand or Redux without converting to client components.

If anyone reading this has a good solve for prop drilling when using server components, please let me know.

One more difficulty I had were was occasionally encountering an "Hydration Error". Unfortunately the React stack trace is pretty incomprehensible for this subtle class of bugs. I have heard rumors that future React releases will show a more helpful stacktrace, but for now the Hydration Overlay package has been a huge help for debugging.

Tailwind CSS

Tailwind CSS was a pleasure to use, and I strongly recommend it for styling. My workflow consisted of first defining a basic design system consisting of a color palette and typography, which I added to a globals.css file, which I imported into my Tailwind theme config. I then designed my app in Figma, making use of auto layout and then used this extremely underrated plugin to generate the code in dev mode.

If you are a VSCode user, I'd also recommend the Tailwind Fold which collapses your class attributes in the code editor, which can get messy really easily.

I used Shadcn heavily as a component library. It uses Tailwind to create modern, simple components that you install by copy-pasting into your codebase. I expect it to become the go-to default when building projects from scratch, and given how easy it is to re-theme, I don't expect us to end up with a future where every website looks the same, the unfortunate outcome of Bootstrap era.

In conclusion

Unlike my previous reservations when it came to my back-end choices, I can definitely see myself using these front-end picks again in future projects.

For more serious projects that I'm highly confident will need to scale, I would recommend using Next.js solely as a front-end framework. While this approach means missing out on cool features like fetching and mutating data with Server Components without needing a separate REST client, it's highly likely that a serious project will eventually require REST endpoints anyway. These endpoints may be needed for a mobile app, or to provide external API access in a B2B application.