An Introduction & Getting Started
After working on another project that implemented feature flags, I decided to create a guide that shows one way of implementing feature flags for a full-stack web application using Starlette and React + React Router. This guide can serve as a foundation for adding support to feature flags to an existing project.
The purpose of this tutorial is to show you an example of possibly implementing feature flags in a full stack application. The techniques shown here demonstrate one of the many ways to implement feature flags. As with everything, there are multiple ways to solve problems
To ensure the smoothest possible experience while following along with this tutorial, we will assume that you have the following installed and ready to go
✅ NodeJS 18/20+ and NPM
✅ Python 3.10+
✅ Pipenv (or virtualenv)
✅ Git
Application Specifics
The API for this project is implemented as a Starlette web application.
The frontend is built using React 18 and React Router 6. There will be an update to React 19 and React Router 7 in the near future.
Project Overview
To get started, start by cloning the starter repository from and navigating to the directory. You should see the following high-level folder structure
https://github.com/saiforceone/fstack-feature-flags
📁 fstack-backend
📁 fstack-frontend
Setting up the backend
Navigate to the backend folder, and initialize the virtual environment using the following command
# initialize environmentpipenv shell
Next, we’ll install our dependencies using the following
# install dependenciespipenvinstall
Finally, we’ll run our backend using the following command
uvicorn app:app--port 5101--reload
--port 5101
specifies that we want to run on local port5101
but you can change this to anything you want as long as there are no conflicts
--reload
enables reload on changes
Testing the backend
Let’s do a quick test of our backend to make sure things are working. To do so, we can use a browser or http client like Postman or Insomnia and navigating tohttp://localhost:5101/api/notes
. When we do this, we should see output that looks like the following because we don’t have any notes yet.
{data:null,message:"",success:false}
Setting up the Frontend
Let’s shift our focus to getting the frontend working. We’ll start by navigating to thefrontend
folder and installing our dependencies with NPM.
npm i# ornpminstall
Creating our.env
file
We will be making using of an environment file to store certain app-specific values. Let’s go ahead and create the following filefrontend/.env
and paste the following
# Base URL for the APIVITE_API_BASE_URL="http://localhost:5101/api"
We can now run our frontend by using the following command
npm run dev
For this project, we are using Tailwindcss which has already been set up for us and is ready to use.
Let’s check out our project by navigating to the following url
http://localhost:5102
We should see something that looks like the image below
This should take care of all the set up part of the project. Next, we’ll get onto the actually work of implementing feature flags on both the backend and frontend
Implementing Feature Flags
Let’s go ahead and create a new branch for repository and naming it accordingly. I’ll be naming my branchimplement-feature-flags
but you can call it whatever you want
git checkout-b implement-feature-flags
Defining The Feature Flag Model
In thebackend/models
folder, we’ll create a new file calledfeature_flag.py
and add the following code
# File: feature_flag.pyfromsqlalchemyimportString,Text,Booleanfromsqlalchemy.ormimportMapped,mapped_columnfrom.base_modelimportBaseModelclassFeatureFlag(BaseModel):__tablename__='feature_flag'id:Mapped[int]=mapped_column(primary_key=True,autoincrement=True)flag:Mapped[str]=mapped_column(String(100),nullable=False)description:Mapped[str]=mapped_column(Text,nullable=True)enabled:Mapped[bool]=mapped_column(Boolean,default=True)defto_dict(self):return{"flag":self.flag,"description":self.description,"enabled":self.enabled,}
Implementing the Feature Flag Controller
in thebackend/controllers
folder, we’ll create a new file calledfeature_flag_controller.py
and add the following code
# File: feature_flags_controller.pyimportsqlalchemyfromstarlette.requestsimportRequestfromstarlette.responsesimportJSONResponsefromstarlette.typesimportScope,Receive,Sendfromcontrollers.base_controllerimportBaseControllerfrommodels.base_modelimportBaseModelfrommodels.feature_flagimportFeatureFlagfromsupport.db_utilsimportdb_utilsclassFeatureFlagsController(BaseController):def__init__(self,scope:Scope,receive:Receive,send:Send):super().__init__(scope,receive,send)self.session=db_utils.session# check if the table exists and creates it if necessaryifnotsqlalchemy.inspect(db_utils.engine).has_table('feature_flag'):BaseModel.metadata.create_all(bind=db_utils.engine,tables=[BaseModel.metadata.tables['feature_flag']])asyncdefget(self,request:Request)->JSONResponse:response=FeatureFlagsController._build_response()# try to retrieve feature flags that are enabledtry:feature_flags=self.session.query(FeatureFlag).where(FeatureFlag.enabled).all()exceptExceptionase:print(f"Failed to retrieve feature flags with error:{e}")response['message']='An unexpected error occurred while retrieving feature flags'returnJSONResponse(response,status_code=500)iflen(feature_flags)==0:response['message']='No feature are available'returnJSONResponse(response,status_code=404)response['data']=[flag.to_dict()forflaginfeature_flags]response['success']=TruereturnJSONResponse(response)
Let’s also update ourcontrollers/__init__.py
file as by addingfrom .feature_flags_controller import FeatureFlagsController
to end of file as shown below
# File: controllers/__init__.pyfrom.notes_controllerimportNotesControllerfrom.feature_flags_controllerimportFeatureFlagsController# <- add this line
Next, we’ll update our routes so that we can fetch our features
# File: routes/__init__.pyfromstarlette.routingimportRoute,MountfromcontrollersimportNotesController,FeatureFlagsController# <- update our import to include FeatureFlagControllerapp_routes:list[Route|Mount]=[Mount('/api',routes=[Route('/notes',NotesController),Route('/notes/{note}',NotesController),Route('/features',FeatureFlagsController),# <- add this line]),]
Let’s test what we have so far by navigating tohttp://localhost:5101/api/features
and seeing what is returned.
{data:null,message:"No feature are available",success:false}
This is normal since we have not yet added any features. Using a database client, we can manually add a feature or two. So feel free to do that, I’ll be adding two features,FE_INLINE_NOTE_DELETE
andNOTE_DELETE
but you can call them whatever you want. We should now see something that looks like the result below
{data:[{flag:"FE_INLINE_NOTE_DELETE",description:"Enables inline note deletion from the note listing"},{flag:"NOTE_DELETE",description:"Enables note deletion"}],message:"",success:true}
Note: we’re excluding theid
andenabled
fields since we don’t need to return theid
of the specific feature and the feature is returned then it must beenabled
.
We’ll come back to the backend to apply this to ourNotesController
.
Updating the Frontend to use the features
So we have a feature flags endpoint that gives us a list of enabled features, let’s see about using this on the frontend
Updating frontend type definitions
Add the following type to ourfrontend/@types/fstack-flags.d.ts
file
exporttypeFeatureFlag={/** * @readonly * * A string representing the name of the flag * * @example `RENDER_NEW_UI` */readonlyflag:string;/** * @readonly * * An optional description for the feature flag * * @example 'Indicates if the new UI should be used instead of the current' */readonlydescription?:string;};
In the same file, we will need to update ourFStackFlagsContext
to include an array of feature flags
exporttypeFStackFlagsContext={/** * Indicates if data is loading at the app-level */dataLoading:boolean;/** * Contains feature flags retrieved from the API */featureFlags:Array<FeatureFlag>;// <- add this};
Implementing the FeatureFlagsServices
We will need a way to retrieve features from the API. Let’s create the filefrontend/services/feature-flags-service.ts
and paste in the following code
// File : services/feature-flags-service.tsimport{typeAPIUtility,BaseAPIResponse,FeatureFlag}from"../@types/fstack-flags";constBASE_URL=import.meta.env.VITE_API_BASE_URL;exportdefaultclassFeatureFlagsServiceimplementsAPIUtility{readonlybaseUrl:string;constructor(){this.baseUrl=`${BASE_URL}/features`;}headers():Headers{returnnewHeaders({'content-type':'application/json',})}/** * @function getFeatures * @description retrieves the available features from the API * @returns {Promise<Array<FeatureFlag> | null>} */asyncgetFeatures():Promise<Array<FeatureFlag>|null>{try{constresponse=awaitfetch(this.baseUrl,{headers:this.headers(),})constjsonData=(awaitresponse.json())asBaseAPIResponse;if(!jsonData.success)returnnull;returnjsonData.dataasFeatureFlag[];}catch(e){console.error(`Failed to retrieve feature flags with error:${(easError).message}`);returnnull;}}}
ℹ️ As an extra step, we could add inbound validation using something like Zod to add that extra layer of safety at runtime.
Updating our context to retrieve feature flags
Now that we have our feature flags service, we will need to fetch enabled features and make them available via ourcontext
. The following code will take care of that
import{createContext,ReactNode,useCallback,useEffect,useState}from"react";importtype{FeatureFlag,FStackFlagsContext}from"../@types/fstack-flags";importFeatureFlagsServicefrom"../services/feature-flags-service.ts";exportconstFStackFEContext=createContext<FStackFlagsContext|null>(null);exportdefaultfunctionFStackFeContextProvider({children}:{children:ReactNode}):ReactNode{const[dataLoading,setDataLoading]=useState<boolean>(false);const[features,setFeatures]=useState<Array<FeatureFlag>>([]);constfetchFeatures=useCallback(()=>{const_exec=async()=>{constfeatureFlagsService=newFeatureFlagsService();constfeatureResponse=awaitfeatureFlagsService.getFeatures();if(!featureResponse)return;setFeatures(featureResponse);}_exec().then();},[]);useEffect(()=>{fetchFeatures();},[fetchFeatures])return<FStackFEContext.Providervalue={{dataLoading,featureFlags:features}}>{children}</FStackFEContext.Provider>;}
If everything worked as expected, you should be able to see our context containing our feature flags similar to the screenshot below
Setting up conditional rendering based on a feature flag
With our feature flags being retrieved from the API and available via context, we will need a way to make use of them in our application. We will be creating a component that will handle things for us.
Let’s create our component calledfeature-wrapper.tsx
in ourcomponents/shared
directory and paste the following code
// File: feature-wrapper.tsximport{typeReactNode,useContext}from"react";import{FStackFEContext}from"../../context/fstack-fe-context.tsx";import{FStackFlagsContext}from"../../@types/fstack-flags";import{hasFeature}from"../../helpers/feature-helpers.ts";typeFeatureWrapperProps={children:ReactNode;requiredFeature:string;}/** * @function FeatureWrapper * @param {ReactNode} children the component to render if the required feature flag is present * @param {string} requiredFeature the flag that is required to render the wrapped component * @constructor * @return {ReactNode} */exportdefaultfunctionFeatureWrapper({children,requiredFeature}:FeatureWrapperProps):ReactNode{const{featureFlags}=useContext(FStackFEContext)asFStackFlagsContext;constcanRender=hasFeature(requiredFeature,featureFlags);returncanRender?children:null;}
In order to test this out, let’s open our notes listingroutes/notes/_index.tsx
and update how we are displaying notes as shown below:
// add state for selected noteconst[selectedNote,setSelectedNote]=useState<Note|null>(null);// add this to delete a noteconstdeleteNote=useCallback(()=>{const_exec=async()=>{if(!selectedNote)return;constnotesService=newNotesService();constdeleteResult=awaitnotesService.deleteNote(`${selectedNote.id}`);if(!deleteResult.success)returnalert("Failed to delete note");fetchNotes();};_exec().then();},[fetchNotes,selectedNote]);
// updated note card renedered<NoteCardkey={`note-${note.id}`}note={note}actionElements={<><NavLinkclassName='underline text-blue-600 flex items-center gap-1'to={`/edit-note/${note.id}`}><BiSolidEdit/> Edit Note</NavLink>{/* Begin - add this block */}<FeatureWrapperrequiredFeature='FE_INLINE_NOTE_DELETE'><buttonclassName='flex items-center gap-1 text-red-600 underline cursor-pointer'onClick={()=>setSelectedNote(note)}><BiSolidTrash/> Delete</button></FeatureWrapper>{/* End - add this block */}</>}/>
{/* add this confirm dialog as the last component in the _index.tsx component right above the closing </PageWrapper> tag*/}{selectedNote?(<ConfirmDialogcontent={<pclassName='text-lg'> You are about to delete the note:{""}<spanclassName='font-medium'>{selectedNote.title}</span>. Would you like to continue?</p>}dialogDismissAction={()=>setSelectedNote(null)}dialogOpen={true}titleText='Delete Note?'dialogActionElements={<divclassName='flex items-center gap-2'><buttonclassName='p-2 rounded bg-slate-800 text-white flex items-center'onClick={()=>setSelectedNote(null)}><BiSolidArrowToLeft/> No, don't delete it</button><buttonclassName='p-2 rounded bg-red-600 text-white flex items-center'onClick={()=>{setSelectedNote(null);deleteNote();}}><BiSolidTrash/> Yes, delete it!</button></div>}/>):null}
Updating the API to facilitate feature flags
we have one thing left to do in order to complete our feature flag implementation which is implementing and using a new decorator function for feature flags. In the backend project, create the filesupport/require_feature_flag.py
and paste the following code
importfunctoolsimportinspectimporttypingfromstarlette.requestsimportRequestfromstarlette.responsesimportJSONResponsefrommodels.feature_flagimportFeatureFlagfrom.db_utilsimportdb_utilsdefrequire_feature_flag(required_flag:str):deffeature_exec_wrapper(func:typing.Callable)->typing.Callable:sig=inspect.signature(func)foridx,parameterinenumerate(sig.parameters.values()):ifparameter.name=="request":breakelse:raiseException(f'No"request" argument on function"{func}"')@functools.wraps(func)asyncdefexec_feature_check(*args,**kwargs):# try to retrieve feature flags matching the required_flagtry:feature_flag=db_utils.session.query(FeatureFlag).where(FeatureFlag.flag==required_flag,FeatureFlag.enabled==True).scalar()exceptExceptionase:print(f"failed to retrieve feature flag with error:{e}")returnJSONResponse({'success':False,'message':'Unexpected error occurred'},status_code=500)iffeature_flagisNone:print(f"the feature:{required_flag} was not found or is invalid")returnJSONResponse({'success':False,'message':'Invalid feature'},status_code=500)returnawaitfunc(*args,**kwargs)returnexec_feature_checkreturnfeature_exec_wrapper
Next, we’ll need to update thedelete
method handler in ournotes_controller.py
to make use of our newly created decorator function. Copy and paste the code below
# update importsfromsupport.require_feature_flagimportrequire_feature_flag# <- add this import# add decorator to delete method handler@require_feature_flag(required_flag='NOTE_DELETE')# <- add this above the delete method handler
With these changes in place, you should be able to enable features for both the frontend and backend part of your application. Play around with enabling and disabling feature flags and observe how the application behaves.
Where to go from here?
This tutorial is meant to give you an example of how you can implement feature flags in a fullstack web application and is not the only method. There are different ways to solve this problem, I would recommend experimentation with some of the other methods such as environment variables, hosted config files, SaaS applications, etc.
Implementing a route cache system (optional)
In this section, we will look at one possible way we can implement caching for a more robust solution. Since we’re using Starlette, we can tap into Starlette’slifespan
event to load the enabled feature flags into application state.
Let’s take a look at some documentation and see how we can implement a simple application cache that will hold our enabled features. See:https://www.starlette.io/applications/#starlette.applications.Starlette
Update the imports in ourapp.py
file
fromcontextlibimportasynccontextmanagerfrommodels.feature_flagimportFeatureFlagfromsupport.db_utilsimportdb_utils
We’re going to implement a lifespan method inside of ourapp.py
file as shown below
@asynccontextmanagerasyncdeflifespan(application):""" This method performs tasks for application startup and shutdown. In this case, we are caching the enabled feature flags on application startup, if none are found, we'll set the cached flags to an empty list :param application: :return:"""try:enabled_features=db_utils.session.query(FeatureFlag).where(FeatureFlag.enabled).all()setattr(application.state,'CACHED_FLAGS',[flag.to_dict()forflaginenabled_features])exceptExceptionase:print(f"failed to retrieve enabled feature flags with error:{e}. Continuing application startup...")setattr(application.state,'CACHED_FLAGS',[])yieldapp=Starlette(debug=True,middleware=middleware,routes=routes,lifespan=lifespan)
Next, we’ll update our decorator function to make use of our cached feature flags while having a fallback mode if something unexpected happens
importfunctoolsimportinspectimporttypingfromstarlette.requestsimportRequestfromstarlette.responsesimportJSONResponsefrommodels.feature_flagimportFeatureFlagfromsupport.db_utilsimportdb_utilsdefrequire_feature_flag(required_flag:str):deffeature_exec_wrapper(func:typing.Callable)->typing.Callable:sig=inspect.signature(func)foridx,parameterinenumerate(sig.parameters.values()):ifparameter.name=="request":breakelse:raiseException(f'No"request" argument on function"{func}"')@functools.wraps(func)asyncdefexec_feature_check(*args,**kwargs):request=kwargs.get("request",args[idx]ifidx<len(args)elseNone)# <- retrieve the request from kwargsassertisinstance(request,Request)# <- assert request is an instance of Request# retrieve feature flags from the application statecached_flags=getattr(request.app.state,'CACHED_FLAGS',[])feature_flag=None# check the list of cached_flagsiflen(cached_flags)>0:# search the cacheforflagincached_flags:ifflag['flag']==required_flag:print("cache hit!")feature_flag=flagelse:print("cache miss")# try to retrieve feature flags matching the required_flagtry:feature_flag=db_utils.session.query(FeatureFlag).where(FeatureFlag.flag==required_flag,FeatureFlag.enabled==True).scalar()exceptExceptionase:print(f"failed to retrieve feature flag with error:{e}")returnJSONResponse({'success':False,'message':'Unexpected error occurred'},status_code=500)iffeature_flagisNone:print(f"the feature:{required_flag} was not found or is invalid")returnJSONResponse({'success':False,'message':'Invalid feature'},status_code=500)returnawaitfunc(*args,**kwargs)returnexec_feature_checkreturnfeature_exec_wrapper
Things to consider
For a more complete implementation, we would need to implement a way to invalidate and refresh the cached feature flags. One thing that comes to mind is using triggers or events provided by an ORM for a specific database model. In theory, when a new feature flag is added or an existing flag is updated, the cached feature flags can be refreshed and the application state will be consistent.
Therequire_feature_flag
wrapper function could be further optimized / cleaned up
We can also implement a better logger system and other optimizations.
Anyway, this is intended as an example of how one could possibly implement feature flags for both the backend and frontend.
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse