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 environment
pipenv shell
Next, we’ll install our dependencies using the following
# install dependencies
pipenv install
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 port 5101
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 to http://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 the frontend
folder and installing our dependencies with NPM.
npm i
# or
npm install
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 file frontend/.env
and paste the following
# Base URL for the API
VITE_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 branch implement-feature-flags
but you can call it whatever you want
git checkout -b implement-feature-flags
Defining The Feature Flag Model
In the backend/models
folder, we’ll create a new file called feature_flag.py
and add the following code
# File: feature_flag.py
from sqlalchemy import String, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from .base_model import BaseModel
class FeatureFlag(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)
def to_dict(self):
return {
"flag": self.flag,
"description": self.description,
"enabled": self.enabled,
}
Implementing the Feature Flag Controller
in the backend/controllers
folder, we’ll create a new file called feature_flag_controller.py
and add the following code
# File: feature_flags_controller.py
import sqlalchemy
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.types import Scope, Receive, Send
from controllers.base_controller import BaseController
from models.base_model import BaseModel
from models.feature_flag import FeatureFlag
from support.db_utils import db_utils
class FeatureFlagsController(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 necessary
if not sqlalchemy.inspect(db_utils.engine).has_table('feature_flag'):
BaseModel.metadata.create_all(bind=db_utils.engine, tables=[BaseModel.metadata.tables['feature_flag']])
async def get(self, request: Request) -> JSONResponse:
response = FeatureFlagsController._build_response()
# try to retrieve feature flags that are enabled
try:
feature_flags = self.session.query(
FeatureFlag
).where(FeatureFlag.enabled).all()
except Exception as e:
print(f"Failed to retrieve feature flags with error: {e}")
response['message'] = 'An unexpected error occurred while retrieving feature flags'
return JSONResponse(response, status_code=500)
if len(feature_flags) == 0:
response['message'] = 'No feature are available'
return JSONResponse(response, status_code=404)
response['data'] = [flag.to_dict() for flag in feature_flags]
response['success'] = True
return JSONResponse(response)
Let’s also update our controllers/__init__.py
file as by adding from .feature_flags_controller import FeatureFlagsController
to end of file as shown below
# File: controllers/__init__.py
from .notes_controller import NotesController
from .feature_flags_controller import FeatureFlagsController # <- add this line
Next, we’ll update our routes so that we can fetch our features
# File: routes/__init__.py
from starlette.routing import Route, Mount
from controllers import NotesController, FeatureFlagsController # <- update our import to include FeatureFlagController
app_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 to http://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
and NOTE_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 the id
and enabled
fields since we don’t need to return the id
of the specific feature and the feature is returned then it must be enabled
.
We’ll come back to the backend to apply this to our NotesController
.
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 our frontend/@types/fstack-flags.d.ts
file
export type FeatureFlag = {
/**
* @readonly
*
* A string representing the name of the flag
*
* @example `RENDER_NEW_UI`
*/
readonly flag: string;
/**
* @readonly
*
* An optional description for the feature flag
*
* @example 'Indicates if the new UI should be used instead of the current'
*/
readonly description?: string;
};
In the same file, we will need to update our FStackFlagsContext
to include an array of feature flags
export type FStackFlagsContext = {
/**
* 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 file frontend/services/feature-flags-service.ts
and paste in the following code
// File : services/feature-flags-service.ts
import { type APIUtility, BaseAPIResponse, FeatureFlag } from "../@types/fstack-flags";
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
export default class FeatureFlagsService implements APIUtility {
readonly baseUrl: string;
constructor() {
this.baseUrl = `${BASE_URL}/features`;
}
headers(): Headers {
return new Headers({
'content-type': 'application/json',
})
}
/**
* @function getFeatures
* @description retrieves the available features from the API
* @returns {Promise<Array<FeatureFlag> | null>}
*/
async getFeatures(): Promise<Array<FeatureFlag>|null> {
try {
const response = await fetch(this.baseUrl, {
headers: this.headers(),
})
const jsonData = (await response.json()) as BaseAPIResponse;
if (!jsonData.success) return null;
return jsonData.data as FeatureFlag[];
} catch (e) {
console.error(`Failed to retrieve feature flags with error: ${(e as Error).message}`);
return null;
}
}
}
ℹ️ 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 our context
. The following code will take care of that
import { createContext, ReactNode, useCallback, useEffect, useState } from "react";
import type { FeatureFlag, FStackFlagsContext } from "../@types/fstack-flags";
import FeatureFlagsService from "../services/feature-flags-service.ts";
export const FStackFEContext = createContext<FStackFlagsContext | null>(null);
export default function FStackFeContextProvider({ children }: { children: ReactNode }): ReactNode {
const [dataLoading, setDataLoading] = useState<boolean>(false);
const [features, setFeatures] = useState<Array<FeatureFlag>>([]);
const fetchFeatures = useCallback(() => {
const _exec = async () => {
const featureFlagsService = new FeatureFlagsService();
const featureResponse = await featureFlagsService.getFeatures();
if (!featureResponse) return;
setFeatures(featureResponse);
}
_exec().then();
}, []);
useEffect(() => {
fetchFeatures();
}, [fetchFeatures])
return <FStackFEContext.Provider value={{ 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 called feature-wrapper.tsx
in our components/shared
directory and paste the following code
// File: feature-wrapper.tsx
import { type ReactNode, useContext } from "react";
import { FStackFEContext } from "../../context/fstack-fe-context.tsx";
import { FStackFlagsContext } from "../../@types/fstack-flags";
import { hasFeature } from "../../helpers/feature-helpers.ts";
type FeatureWrapperProps = {
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}
*/
export default function FeatureWrapper({ children, requiredFeature }: FeatureWrapperProps): ReactNode {
const { featureFlags } = useContext(FStackFEContext) as FStackFlagsContext;
const canRender = hasFeature(requiredFeature, featureFlags);
return canRender ? children : null;
}
In order to test this out, let’s open our notes listing routes/notes/_index.tsx
and update how we are displaying notes as shown below:
// add state for selected note
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
// add this to delete a note
const deleteNote = useCallback(() => {
const _exec = async () => {
if (!selectedNote) return;
const notesService = new NotesService();
const deleteResult = await notesService.deleteNote(`${selectedNote.id}`);
if (!deleteResult.success) return alert("Failed to delete note");
fetchNotes();
};
_exec().then();
}, [fetchNotes, selectedNote]);
// updated note card renedered
<NoteCard
key={`note-${note.id}`}
note={note}
actionElements={
<>
<NavLink
className='underline text-blue-600 flex items-center gap-1'
to={`/edit-note/${note.id}`}
>
<BiSolidEdit />
Edit Note
</NavLink>
{/* Begin - add this block */}
<FeatureWrapper requiredFeature='FE_INLINE_NOTE_DELETE'>
<button
className='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 ? (
<ConfirmDialog
content={
<p className='text-lg'>
You are about to delete the note:{" "}
<span className='font-medium'>{selectedNote.title}</span>. Would you like to continue?
</p>
}
dialogDismissAction={() => setSelectedNote(null)}
dialogOpen={true}
titleText='Delete Note?'
dialogActionElements={
<div className='flex items-center gap-2'>
<button
className='p-2 rounded bg-slate-800 text-white flex items-center'
onClick={() => setSelectedNote(null)}
>
<BiSolidArrowToLeft />
No, don't delete it
</button>
<button
className='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 file support/require_feature_flag.py
and paste the following code
import functools
import inspect
import typing
from starlette.requests import Request
from starlette.responses import JSONResponse
from models.feature_flag import FeatureFlag
from .db_utils import db_utils
def require_feature_flag(required_flag: str):
def feature_exec_wrapper(func: typing.Callable) -> typing.Callable:
sig = inspect.signature(func)
for idx, parameter in enumerate(sig.parameters.values()):
if parameter.name == "request":
break
else:
raise Exception(
f'No "request" argument on function "{func}"'
)
@functools.wraps(func)
async def exec_feature_check(*args, **kwargs):
# try to retrieve feature flags matching the required_flag
try:
feature_flag = db_utils.session.query(
FeatureFlag
).where(
FeatureFlag.flag == required_flag, FeatureFlag.enabled == True
).scalar()
except Exception as e:
print(f"failed to retrieve feature flag with error: {e}")
return JSONResponse({
'success': False,
'message': 'Unexpected error occurred'
}, status_code=500)
if feature_flag is None:
print(f"the feature: {required_flag} was not found or is invalid")
return JSONResponse({
'success': False,
'message': 'Invalid feature'
}, status_code=500)
return await func(*args, **kwargs)
return exec_feature_check
return feature_exec_wrapper
Next, we’ll need to update the delete
method handler in our notes_controller.py
to make use of our newly created decorator function. Copy and paste the code below
# update imports
from support.require_feature_flag import require_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’s lifespan
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 our app.py
file
from contextlib import asynccontextmanager
from models.feature_flag import FeatureFlag
from support.db_utils import db_utils
We’re going to implement a lifespan method inside of our app.py
file as shown below
@asynccontextmanager
async def lifespan(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() for flag in enabled_features])
except Exception as e:
print(f"failed to retrieve enabled feature flags with error: {e}. Continuing application startup...")
setattr(application.state, 'CACHED_FLAGS', [])
yield
app = 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
import functools
import inspect
import typing
from starlette.requests import Request
from starlette.responses import JSONResponse
from models.feature_flag import FeatureFlag
from support.db_utils import db_utils
def require_feature_flag(required_flag: str):
def feature_exec_wrapper(func: typing.Callable) -> typing.Callable:
sig = inspect.signature(func)
for idx, parameter in enumerate(sig.parameters.values()):
if parameter.name == "request":
break
else:
raise Exception(
f'No "request" argument on function "{func}"'
)
@functools.wraps(func)
async def exec_feature_check(*args, **kwargs):
request = kwargs.get("request", args[idx] if idx < len(args) else None) # <- retrieve the request from kwargs
assert isinstance(request, Request) # <- assert request is an instance of Request
# retrieve feature flags from the application state
cached_flags = getattr(request.app.state, 'CACHED_FLAGS', [])
feature_flag = None
# check the list of cached_flags
if len(cached_flags) > 0:
# search the cache
for flag in cached_flags:
if flag['flag'] == required_flag:
print("cache hit!")
feature_flag = flag
else:
print("cache miss")
# try to retrieve feature flags matching the required_flag
try:
feature_flag = db_utils.session.query(
FeatureFlag
).where(
FeatureFlag.flag == required_flag, FeatureFlag.enabled == True
).scalar()
except Exception as e:
print(f"failed to retrieve feature flag with error: {e}")
return JSONResponse({
'success': False,
'message': 'Unexpected error occurred'
}, status_code=500)
if feature_flag is None:
print(f"the feature: {required_flag} was not found or is invalid")
return JSONResponse({
'success': False,
'message': 'Invalid feature'
}, status_code=500)
return await func(*args, **kwargs)
return exec_feature_check
return feature_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.
The require_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)