How I Structure a React and Supabase Project Before Writing the First Feature

 


When I first started building web products, I treated project structure like something I could solve later. I wanted to see the page working. I wanted the button to click. I wanted the database to return something. If the feature worked, I felt like I was moving.

That approach is exciting for the first few days. It becomes expensive when the project starts growing.

A React project connected to Supabase can become messy surprisingly fast. Authentication logic ends up inside page components. Database calls get copied into different files. Environment variables are scattered around. A small change starts touching five places. Then you reach the point where adding one feature feels harder than building the first version of the whole app.

I have learned to slow down at the beginning.

I do not try to design a perfect architecture. I just make enough decisions early that the project has somewhere sensible to grow.

I start with the product, not the folders

Before creating directories, I write down what the first useful version of the product actually needs.

For a simple app, that could be:

  • a public landing page;
  • user sign-up and sign-in;
  • a protected dashboard;
  • one main database table;
  • create, read, update and delete actions;
  • a profile;
  • basic error and loading states.

That list matters because the structure should support the product. I do not want ten folders that exist because a tutorial told me they should exist.

If the first version does not need a complicated state-management layer, I do not add one. If I only have two database operations, I do not create an enormous service architecture. I want the project to be organized, not ceremonial.

My basic React structure

For a normal Vite + React project, I am comfortable starting with something like this:

src/
  components/
  pages/
  features/
  hooks/
  lib/
  services/
  utils/
  App.jsx
  main.jsx

Each folder has a simple job.

components is for reusable interface pieces. A button, modal, navigation bar or form field can live there.

pages represents routes or major screens.

features is useful when a product grows. I can group code around something the user actually does, such as authentication, payments, profiles or listings.

hooks is where reusable React logic can live.

lib is where I put setup code for external libraries, including the Supabase client.

services is where I prefer to keep reusable calls to the backend instead of spreading raw database queries across every component.

utils contains small helpers that do not belong to a feature.

This is not the only correct structure. That is the point. I need a structure I can understand months later.

I isolate the Supabase client immediately

The current Supabase React quickstart uses Vite and recommends keeping the project URL and publishable key in environment variables. I like that pattern because every part of the app can import one initialized client rather than creating new clients everywhere.

My .env.local starts like this:

VITE_SUPABASE_URL=your-project-url
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key

Then I create a file such as:

// src/lib/supabaseClient.js
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabasePublishableKey =
  import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY

export const supabase = createClient(
  supabaseUrl,
  supabasePublishableKey
)

One warning matters here: a browser application is not the place for a Supabase service-role key. The frontend should use the publishable/anon-style client key intended for browser use, while database access is protected with proper Row Level Security policies.

Environment variables are not magic hiding places in a frontend build. Anything intentionally used by browser code can ultimately be seen by the browser. Security must come from authorization rules, not from hoping a public client credential remains secret.

I separate database access from the interface

One mistake I used to make was putting queries directly inside whichever component needed the data.

That works:

const { data } = await supabase
  .from('profiles')
  .select('*')

But once the same operation is needed in three places, the project starts repeating itself.

I prefer creating focused functions:

// src/services/profileService.js
import { supabase } from '../lib/supabaseClient'

export async function getProfile(userId) {
  return supabase
    .from('profiles')
    .select('*')
    .eq('id', userId)
    .single()
}

The React component can now care about displaying the result instead of knowing every detail about how the query works.

This separation also helps when I need to change the schema later. I would rather update a small service than hunt through ten JSX files.

Authentication gets its own boundary

Authentication touches more of an app than people expect.

It affects:

  • navigation;
  • protected routes;
  • loading states;
  • profile creation;
  • database policies;
  • session recovery;
  • password reset flows;
  • email confirmation.

Because of that, I do not want authentication logic mixed randomly into unrelated components.

For a smaller project, a React context or a dedicated auth hook may be enough. I want one place responsible for answering basic questions:

  • Is there a user?
  • Is the session still loading?
  • How does the user sign out?
  • What should happen when auth state changes?

Then the rest of the application consumes that answer.

The goal is not to create a giant abstraction. It is to stop every page from inventing its own version of authentication.

I think about Row Level Security before real data enters the app

Supabase makes it easy to query tables from the client. That convenience can create a dangerous mindset: if a query works in development, it must be ready.

It is not.

Before I treat a table as production-ready, I ask who should be allowed to read, insert, update and delete each row.

For a profile table, perhaps a user can read public profiles but update only their own profile.

For a private transaction-style table, a user may only be allowed to see records connected to their account.

The exact policy depends on the product. The important part is making authorization part of the design rather than a cleanup task after launch.

I create predictable UI states

A feature is not finished just because the success case works.

For almost every request, I think about four states:

  1. idle;
  2. loading;
  3. success;
  4. error.

If the interface has data that can legitimately be empty, I add an empty state too.

This sounds basic, but it changes how professional an application feels. A blank dashboard can mean “nothing has loaded,” “there is no data,” or “the request failed.” Users should not have to guess which one happened.

I keep configuration out of random components

API URLs, feature flags, route names and other configuration tend to spread when I am moving fast.

If a value can change between environments or deployments, I want a clear place for it. If it is sensitive server-side information, it does not belong in a client bundle at all.

This becomes especially important when a project moves from local development to Netlify, Vercel or another hosting platform. The deployment environment should contain the appropriate environment values rather than relying on secrets committed into Git.

I decide the first deployment path early

I do not wait until the whole product feels finished before deploying.

A project behaves differently outside localhost. Redirect URLs matter. Environment variables may be missing. Authentication callbacks can fail. Case-sensitive file paths can suddenly break. A build command that worked on my computer might not work in the hosting environment.

So I like getting a basic version deployed early.

That gives me a real production URL and forces me to solve deployment assumptions before the codebase becomes large.

The structure should make the next change easier

I do not judge a project structure by how impressive the folder tree looks.

I judge it by what happens when I need to make the next change.

Can I find the code?

Can I understand where a backend request belongs?

Can I change one feature without breaking an unrelated page?

Can another developer open the project and get a reasonable idea of how it is organized?

If the answer is yes, the structure is doing its job.

The biggest change in how I build is that I no longer see setup as time stolen from development. A small amount of deliberate structure is part of development.

It gives the product room to grow without turning every future feature into cleanup work.

That is the balance I want now: not over-engineered, not careless, just organized enough that tomorrow's version of me does not have to fight yesterday's code.

Technical references

  • Supabase React quickstart: https://supabase.com/docs/guides/getting-started/quickstarts/reactjs
  • Supabase Auth with React: https://supabase.com/docs/guides/auth/quickstarts/react

Post a Comment

Previous Post Next Post

Contact Form