

How I made a Personal Finance Tracker using ReactJS and AWS
I made a personal finance tracker that used ReactJS, Firebase (for authentication) and various AWS services such as API Gateway, DynamoDB and Lambda. You can check it out at https://budgetly.hamdtel.co.uk.
I first started by quickly putting together a basic login UI with ReactJS and Tailwind CSS.
I then started thinking about how I should go about architecting the backend of my app. I wanted to initially familiarise myself with using basic AWS services so I decided to use Firebase over Cognito for now (although in my next project I will learn and use Cognito). I created my Firebase project and added a web app and a service account so I could communicate with it from my frontend. Then I created a new test user in the authentication service, installed the firebase-admin
npm package into my React project and created firebase-config.js
:
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
const firebaseConfig = {
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.REACT_APP_FIREBASE_APP_ID
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
I used environment variables in the configuration file for security. This code initialised Firebase any time I imported it into a different file and since the only thing I really needed it for was authentication, I only exported that service so other files could use it.
I was originally planning to just store the UUID of the user in cookies, however that is not very secure so I looked for an alternative. Typically when a user signs in, a 'session token' or 'ID token' is returned. This is temporary and expires after some time, and can be used to retrieve the UUID if you have the service account key. I therefore stored the ID token in cookies and, for testing purposes, outputted it in the console on successful login.
Now I needed to use this to be able to retrieve user data (expenses, incomes, subscriptions etc.). Before launching anything on AWS, I used Figma to map out how I would handle storing data in databases and efficiently reading and writing to it; I decided to make it so that the latest 3 months or so of database records were retrieved on login and stored in cookies as a cache - that way database queries could be kept to a minimum. If needed though the application would be able to retrieve more records and add them to the cache. Because of this the best way to retrieve records would be to make a way to specify the ID Token, a start date and an end date and get back all database records for the specific user between both dates.
Now it was time to start deploying the server-side infrastructure. I started with deploying the database which I chose DynamoDB for. I used userId
as the partition key and transactionDate
as the sort key as these two attributes would be what I would use to query.
I made a quick example record that I could use to make sure that I could read from the table.
The next step was to create the Lambda function that when invoked could take the three parameters I mentioned earlier (idToken
, startDate
, endDate
) and return the correct records from the database. I made the function and started to write the code for it on my local computer.
I first installed the AWS SDK to communicate with DynamoDB and then firebase-admin
so I could swap the ID Token for the UUID. Then I wrote the code inside index.js
:
const AWS = require('aws-sdk');
const admin = require('firebase-admin');
const dynamoDB = new AWS.DynamoDB.DocumentClient();
exports.handler = async (event) => {
const { idToken, startDate, endDate } = event.queryStringParameters;
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
const uuid = decodedToken.uid;
if (new Date(startDate) > new Date(endDate)) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'startDate must be less than or equal to endDate' }),
};
}
const params = {
TableName: 'Budgetly',
KeyConditionExpression: 'userId = :userId AND transactionDate BETWEEN :startDate AND :endDate',
ExpressionAttributeValues: {
':userId': uuid,
':startDate': startDate,
':endDate': endDate,
},
};
const data = await dynamoDB.query(params).promise();
return {
statusCode: 200,
body: JSON.stringify(data.Items),
};
} catch (e) {
console.error(e);
return {
statusCode: 500,
body: JSON.stringify({ error: e }),
};
}
};
The code took those three parameters and first used the Firebase SDK to retrieve the UUID by using the ID Token. Then it queried the database using the condition expression userId = :userId AND transactionDate BETWEEN :startDate AND :endDate
. The final thing to do before testing it was to create an API that could be used to invoke the function.
I made a GET route called /data
that used the Lambda integration type. When accessed it would expect the three parameters provided as query string paramters (e.g. /data?idToken=<ID_TOKEN>&startDate=2024-01-01&endDate=2024-12-31
) and send them to the Lambda function.
However when testing it, it kept failing. I then realised that I forgot to change the Lambda execution role's policy to allow DynamoDB queries. It already had CloudWatch permissions (so it could perform logging) so I added the dynamodb:Query
permission.
After changing that, I got the following result in Postman:
The reading operation now worked! I could provide an idToken
and a timeframe and I would get the correct records!
I now had to add to the API and Lambda to add support for writing records as well. I did it in mostly the same way: in the parameters I would have to provide the ID Token, the amount, the name and the category.
I created a second Lambda function for this and wrote the code on my computer again. I installed the AWS SDK and the Firebase SDK again as I would need to use both. Then I wrote the code:
const AWS = require('aws-sdk');
const admin = require('firebase-admin');
const dynamoDB = new AWS.DynamoDB.DocumentClient();
const serviceAccount = require('./sak.json');
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
}
exports.handler = async (event) => {
const { idToken, amnt, category, name } = event.queryStringParameters;
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
const uuid = decodedToken.uid;
const currentDate = new Date().toISOString();
const params = {
TableName: 'Budgetly',
Item: {
userId: uuid,
transactionDate: currentDate,
amount: +amnt,
category: category,
name: name,
},
};
await dynamoDB.put(params).promise();
return {
statusCode: 200,
body: JSON.stringify({ message: "Data successfully written" })
};
} catch (e) {
console.error(e);
return {
statusCode: 500,
body: JSON.stringify({ error: e })
};
}
};
The code was also very similar to the other Lambda function except that it used the PutItem
API instead of the Query
API.
After uploading the code to my Lambda function I then remembered to change the execution role's policy to allow dynamodb:PutItem
.
I then went back to API Gateway to create a second route for writing to the database:
I also attached the Lambda integration:
After finishing that I went to Postman to test that the API worked... and it didn't:
My code was meant to give me a detailed error message if something did go wrong but it didn't so the error was likely coming from improper configuration.
I looked into the Lambda function and didn't find anything that could be wrong but I realised that write operations was likely to take some time - so I raised the timeout of the Lambda function from 3 seconds to 15 seconds for good measure.
I went back into Postman and tried again, and it worked! It only took 5 seconds for the entire process to complete.
Now there was only one more thing to check: I went into DynamoDB and, sure enough, the record was now saved!
By this point most of the server-side work was complete. I added a registration page, a very simple homepage and fixed up a lot of the routing. I also made it refresh the ID token every half an hour.
Now I had to implement the 'caching' mechanism. I planned to make it so that the latest 3 months of records for that user were retrieved on login and then I would be able to add to that anytime the app retrieved records that weren't in the cache. However the cache was probably going to be too big for some users - so for good measure I also planned to use lz-string
to compress the data before storing it.
Before going about writing the code for this I added a small part to the homepage that could allow you to write in values and submit them to be written to the database for ease of use.
In the login function I added a part that used a simple fetch function however I kept facing a very persistent error, and it didn't help that it wasn't very helpful...
I was stumped on this for a while, because the API worked when I tested it in the browser and Postman. I then realised it's probably a CORS issue - I tried doing some research and, sure enough, it was. I had faced these types of issues in the past (like with Flask), but I had no idea how to tackle it. I went back into the AWS Console and saw a tab labelled 'CORS'! Once I went inside it I saw this field:
So it wasn't as big of an issue as I thought. I added localhost for testing purposes but would have to remember to add the production URL later.
I fixed up the caching mechanism and added a loading screen to the dashboard on initial login while it waited for the cache
cookie to appear, as the API call, obviously, took time. I then fixed up the auth pages to look a little better and added a side panel to show the features of the app:
Before continuing with designing the dashboard I remembered that I would need a system to store user metadata: data such as default currency, custom categories etc.
To be as efficient as possible I repurposed the transactionDate sort key and the data reading API to also get the metadata if requested:
const AWS = require('aws-sdk');
const admin = require('firebase-admin');
const dynamoDB = new AWS.DynamoDB.DocumentClient();
const serviceAccount = require('./sak.json');
const currenies = require('./currencies.json');
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
}
exports.handler = async (event) => {
const { idToken, locale } = event.queryStringParameters;
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
const uuid = decodedToken.uid;
const params = {
TableName: 'Budgetly',
Item: {
userId: uuid,
transactionDate: "meta",
defaultCurrency: currenies[locale] || 'USD',
},
};
await dynamoDB.put(params).promise();
return {
statusCode: 200,
body: JSON.stringify({ message: "Data successfully written", currency: currenies[locale] || 'USD' })
};
} catch (e) {
console.error(e);
return {
statusCode: 500,
body: JSON.stringify({ error: e })
};
}
};
So now on login I could also store the metadata in a separate cookie.
I then worked on the homepage and managed to get it to look like this:
I was happy with this design, so I had to get on with designing the onboarding page.
The onboarding page would need to guide the user through the capabilities of my app, and also collect metadata such as default currency, spending limits, and savings goals.
I spent some time designing the UI and this was the result:
At this point, most of the MVP (Minimum Viable Product) was complete. All I had to do was design a UI for the homepage:
Now, the app was ready to deploy.
I have a domain on Route 53, but to save costs with deploying on Elastic Beanstalk (as this is an MVP and I didn't want to incur costs for the underlying EC2 instances), I decided to deploy it to Vercel and then update DNS records on Route 53. I would also have to remember to update the CORS policy on API Gateway.
First I committed and pushed the source code to GitHub:
And then I used the repository to deploy to Vercel:
Now I had to add the DNS records to Route 53.
And the final step was to allow CORS on the domain:
And the MVP was complete!
Thank you for reading!
- Hamd Waseem (14)