Budgeting is hard, especially when you are trying to make it into a habit. In this difficult time, when your brain surprises you with unmatched efficiency in creating new excuses, any sort of help is welcome. In this tutorial, I will share my knowledge on how to leverage our programming skills to streamline the process of tracking expenses in a simple app made by connecting Next.js with Google Spreadsheets.
There is nothing I like more than convincing myself it’s worth spending 10 hours optimizing a process I’m just getting started on, even if only to run from the nitty-gritty grind of it. That’s why I have a graveyard of fancy, colorful spreadsheets that were supposed to kick off a certain idea of mine, but instead are a representation of how difficult it is to learn from your own mistakes.
Apparently – I am not alone. According to data from Clutch, up to 60% of Americans feel comfortable with preparing their personal budget, but only 40% do so on a regular basis.
This time it is going to be different, though. I’ve managed to identify the reason why consistent budgeting has been so hard for me: it’s the inconvenience of updating a Google Spreadsheet from a smartphone. Now the process of maintaining your personal, online budget will be seamlessly as good as your experience of tracking expenses is.
Some people are more old-school, working things out with pen and paper, and I envy them. I get anxious if I don’t have everything in the cloud, that’s why I put some of the tools I have in my toolbox as a developer to work.
Building a budgeting app with Node.js and Google Sheets
The problem we will try to solve today is in connecting the Next.js app with a Google Spreadsheet. The challenge is both in reading from it as well as writing to it. If we were to build a more traditional web app, I would not recommend using Google Spreadsheet as your database. However, in this case, I have a good reason for it: I want to use the features of a spreadsheet (graphing, logic, formatting data, familiar interface) while making the process of updating it more accessible. The core of our budgeting solution lies in the spreadsheet, our app has to blend with and in it. Basically – my goal was to deliver a convenient front-end to the spreadsheet I use for my personal budgeting.
The way I envisioned my perfect budgeting solution working is the following:
- I have two sheets in my Google Spreadsheet document. One contains categories of expenses (like “Food,” “Transportation,” “Bills”) and the other contains the expenses themselves. Each expense is described by a set of attributes:
– Amount (of money that was spent)
– Date (of when it was spent)
– Category (from the first sheet)
– Description (like “Starbucks Coffee”) - I log in to the app, and authorize myself with a Google Account.
- I add an expense in a simple interface by providing data in the format described above.
- My spreadsheet is updated once the request is successfully submitted.
And rejoice, as within this tutorial we will build an app that accomplishes precisely this scenario. However, it will do so under certain limitations:
- You need to have a Google Cloud account set up with a debit card. We will not do anything that requires any payments, but it’s a requirement from Google to use their services.
- Our application will not store any data other than what we write on our spreadsheet.
The stack we will use is:
- TypeScript – because there is no way in hell I am writing an app without it
- Next.js – because we need server-side to work with the Google Spreadsheet API
- googleapis – to access the Spreadsheet API
- next-auth – because it handles the user session and authentication
- react-hot-toast – to display toast messages
- react-hook-form – for form management
- react-query – for queries/mutations execution
- tailwindcss – for styling
- framer-motion – for animations
Wow, that’s quite a lot of technology for such a simple website, right? The core features lie in Next.js & Google APIs, and we will focus on those (and other underlined dependencies), so you can definitely make your own choices on the rest. I plan to expand this application in the future, so I went with the libraries I know and like, or with those I wanted to learn anyway.
This guide would work best for a developer with a basic knowledge of TypeScript, familiarity with the React ecosystem, and who is new to Next.js and Google APIs. Yet I will do my best to provide guidance for everyone willing to gain new knowledge and insights through a hands-on project.
Agenda:
- Spreadsheets setup
- Initial Next.js application configuration
- Setting up NextAuth to authenticate the user with Google
- Working with Google API and building the interface
- Deployment
Spreadsheets Setup
First of all, you need to log in to your Google account and head to Google Sheets. There, we’ll create a new spreadsheet with two sub sheets inside. The first one I will call “Categories” and the second one will be “Expenses.”
This is what your typical “Categories” sub sheet might look like:
Then, you may want to set up your “Expenses” similarly to this:
I added an exemplary expense to present what our data will look like.
The rest is up to you – you can create other sheets with graphs, financial plans, and so on. Our application will only affect these two sub sheets.
2. Initial Next.js application configuration
If you have never worked with Next.js before, you should know it comes with a CLI that generates a solid boilerplate we can immediately start working with. The command is:
npx create-next-app@latest –ts expense-tracker
Where the “–ts” attribute stands for TypeScript and “expense-tracker” is the name of the app, which you can obviously replace with your own unique name.
I recommend doing some housekeeping first by removing some files we will not need:
And also to get rid of the markdown in “pages/index.tsx” to make it look something like this.
// pages.index.tsx
import type { NextPage } from 'next'
const Home: NextPage = ()
=> {
return (
<main>Hello World</main>
)
}
export default Home
Then, we can get on with installing the main dependencies:
npm i googleapis next-auth react-hook-form react-hot-toast react-query
And if you want to be 100% on the same page with me, you can also add:
npm i @svgr/webpack @tailwindcss/forms framer-motion tailwindcss
3. Setting up NextAuth to authenticate the user with Google
Finance data is as confident as data gets, therefore we need to authenticate our users first. We would not want anyone to be able to see how much an adult can spend on Lego sets, isn’t that right? This could also be crucial for developers willing to add some more features in the upcoming future – the secure environment will be ready.
Thankfully, a nice little package called “next-auth” has us covered. It comes with an impressive list of providers – services we can use to sign a user in. Setting up NextAuth is a straight-forward process and it goes like this:
- First, we create the file under the path and name: pages/api/auth/[…nextauth].ts
This combination is quite peculiar, you may say. Well, let’s break it down:
- the “pages” part comes from Next.js file-based routing
- the “api” part represents a feature from Next.js called API Routes that enables us to write an endpoint under the URL “host/api/…,” so a request to “https://expense-tracker.com/api/ping” will cause Next.js to try to resolve it with a “ping.js” file inside the “pages/api” directory. That file must contain logic for processing this request.
- the “[…nextauth].ts” – this is Next.js syntax for a dynamic, catch-all route.
This exact path is required by next-auth to correctly execute the code on the server-side.
- Then, we configure our provider:
import NextAuth from "next-auth"
import Providers from "next-auth/providers"
export default NextAuth({
providers: [
Providers.Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
authorizationUrl:
"https://accounts.google.com/o/oauth2/v2/auth?prompt=consent&access_type=offline&response_type=code",
scope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/spreadsheets",
}),
],
callbacks: {
async jwt(token, user, account, profile, isNewUser) {
if (account?.accessToken) {
token.accessToken = account.accessToken
}
return token
},
},
secret: process.env.SECRET,
})
What you can see here is Next.js’ ability to consume environment variables in a “.env” file. Any variable that is supposed to surface the client needs to begin with “NEXT_PUBLIC”, but our operations will be executed on the server-side, so no need to prefix them with anything.
We pass four arguments to our NextAuth Google Provider: “clientId,” “clientSecret,” “authorizationUrl” and “scope.”
Providers.Google({
clientId: "???",
clientSecret: "???",
authorizationUrl: "???",
scope: “???”
}),
How To Obtain Google ClientId And ClientSecret?
- Go to Google Cloud Console and create a project.
- Go to Credentials
- Click on “Create Credentials” and pick “OAuth client ID”
- You will not be able to continue without configuring the consent screen, so go here:
- Pick “User Type” -> “External” and then on the next screen, make sure to add the address you will deploy the website to in the “Authorized domains” section. Then, in the “Test users” screen, please add the email assigned to the Google account you created the Spreadsheet with.
- Then, after successfully filling out the form, you should see your newly created application in the “OAuth 2.0 Client IDs” list on the “Credentials” screen. Click on your project to see the Client ID and the Client Secret on the right.
Copy those values and save them as environment variables in “.env.local” named GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.
Then, we have the “authorizationUrl” argument. The reason why we added it is described in Next-Auth Google Provider documentation in the “Warning” section:
“…you can also pass options in the authorizationUrl which will force the Refresh Token to always be provided on sign in, however, this will ask all users to confirm if they wish to grant your application access every time they sign in.
If you need access to the RefreshToken or AccessToken for a Google account and you are not using a database to persist user accounts, this may be something you need to do.”
So, basically, we copy-paste it.
The last part in terms of configuring for Google Provider is “scope.” It contains a set of URLs (pay attention, it’s not an array of strings, just one very long string!) Google uses to grant the user rights to specific parts of its ecosystem. What interests us are the “UserInfo” and “Spreadsheets” APIs. While we are at it, we need to enable the Google Spreadsheets API in our Google Cloud Console. You can do it here.
Then, put this bit in the “callbacks”:
callbacks: {
async jwt(token, user, account, profile, isNewUser) {
if (account?.accessToken) {
token.accessToken = account.accessToken
}
return token
},
},
As it will come in handy when we want to authenticate requests to our Google Spreadsheet API with our freshly obtained token. If you want to read more about Next-Auth callbacks, please head here.
And finally, the last part of our Next-Auth configuration:
secret: process.env.SECRET,
The secret is just a randomly generated (by yourself) string that Next-Auth recommends using to “avoid invalidating end-user sessions when configuration changes are deployed.”
Now, let’s head to “pages/_auth.tsx” and wrap up our application with a Session provider:
// pages/_auth.tsx
import React from "react"
import type { AppProps } from "next/app"
import { Provider } from "next-auth/client"
export default function App({ Component, pageProps }: AppProps) {
return (
<Provider session={pageProps.session}>
<Component {...pageProps} />
</Provider>
)
}
If you want to mimic my stack, you should also include the rest of these providers:
// pages/_auth.tsx
import "tailwindcss/tailwind.css"
import React from "react"
import { Toaster } from "react-hot-toast"
import { QueryClient, QueryClientProvider } from "react-query"
import type { AppProps } from "next/app"
import { Provider } from "next-auth/client"
const queryClient = new QueryClient()
export default function App({ Component, pageProps }: AppProps) {
return (
<Provider session={pageProps.session}>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
<Toaster />
</QueryClientProvider>
</Provider>
)
}
And that’s it! The user can now access next-auth’s “signIn” and “signOut” methods to do exactly that. The simplest snippet would look something like this:
// pages/index.tsx
import React from "react"
import { signIn, useSession } from "next-auth/client"
export default function Home() {
const [session] = useSession()
return <main>{!session && <button onClick={() => signIn()
}>Sign In</button>}</main>
}
But I, however:
- added some logic to redirect the user to my second route: “/expenses” if the user is logged in
- used a custom Button component with some Tailwind CSS classes
Therefore:
// pages/index.tsx
import React from "react"
import { useRouter } from "next/router"
import { signIn, useSession } from "next-auth/client"
import { Button } from "../src/components/Button"
export default function Home() {
const router = useRouter()
const [session] = useSession()
React.useEffect(() => {
if (session) {
router.push("/expenses")
}
}, [session, router])
return (
<main className="flex justify-center items-center h-full">
{!session && (
<Button className="px-6 py-6 w-36"
onClick={() => signIn()}>
Sign In
</Button>
)}
</main>
)
}
Then, I created a “Nav” component, where I use the “signOut” function. The basic version would look like this:
code lang=”typescript” class=” language-typescript”>import React from “react”import { signOut, useSession } from “next-auth/client”
export const Nav = () => {
const [session] = useSession()
return <nav>{session && <button onClick={() =>signOut()}>Logout</button>}</nav> }
And then, I put some fireworks in it:
import React from "react"
import Link from "next/link"
import { signOut, useSession } from "next-auth/client"
import LogoutIcon from "../../public/icons/logout.svg"
import { Button } from "./Button"
export const Nav = () => {
const [session] = useSession()
return (
<nav className="bg-white px-4 py-8 w-full flex items-center justify-around">
<h1 className="text-xl font-bold text-green-600 underline tracking-wide">
<Link href="/" passHref>
<a>Expense Tracker 🙂</a>
</Link>
</h1>
{session && (
<Button className="w-28 py-2 px-2 flex items-center gap-2" variant="outline" onClick={() => signOut()}>
Logout
<LogoutIcon />
</Button>
)}
</nav>
)
}
Once configured, next-auth will handle the rest. You must simply declare entry points for it to work (“signIn” and “signOut”).
4. Working with Google API and building the interface
As you remember, in order to be able to add an expense, we must know what categories of expenses we accept. We stored those in our “Categories” sub sheet and now we must fetch them.
Let’s create the API route: “pages/api/categories.ts”. All Next.js API routes may start with:
// pages/api/categories.ts
import type { NextApiHandler } from "next"
const CategoriesHandler: NextApiHandler = async (req, res) => {
// ...
}
Then, we need to extract the token we stored with a callback to our provider in “[…nextauth.ts]”:
// pages/api/categories.ts
import type { NextApiHandler } from "next"
import { getToken } from "next-auth/jwt"
const secret = process.env.SECRET
const CategoriesHandler: NextApiHandler = async (req, res) => {
const token = await getToken({ req, secret })
}
export default CategoriesHandler
Next, we need to authenticate our application:
// ...
const clientId = process.env.GOOGLE_CLIENT_ID
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
const auth = new google.auth.OAuth2({
clientId,
clientSecret,
})
And also, authenticate our user:
// ...
const accessToken = token?.accessToken as string | undefined
auth.setCredentials({
access_token: accessToken,
})
Then, we can finally query our Google Sheets API:
const sheets = google.sheets({ version: "v4", auth }) // 🙂 access the API
const range = `${process.env.CATEGORIES_SHEET_NAME ??
"Categories"}!A2:B` // 🙂 specify the range
const response = await sheets.spreadsheets.values.get({
spreadsheetId: process.env.SHEET_ID,
range,
}) // 🙂 fetch the data
Notice the usage of two new environment variables: “CATEGORIES_SHEET_NAME” and “SHEET_ID.”
We are not necessarily building an application that has to fit the needs of the general public, but we still want some flexibility. That’s why we enable the user to provide the name of the sheet that contains the categories via env var – CATEGORIES_SHEET_NAME. If it is not provided, we want to fall back to “Categories,” which is what I named it in my example.
The SHEET_ID we can get from the Spreadsheet URL:
In the “range” variable declaration, we can also see the “!A2:B” part, which is a way of saying “fetch all available records in the column A and B from the second row down”:
In the end, we need to return the data with the “res.json()” function, which leaves the whole file looking like this:
// pages/api/categories.ts
import { google } from "googleapis"
import type { NextApiHandler } from "next"
import { getToken } from "next-auth/jwt"
const secret = process.env.SECRET
const CategoriesHandler: NextApiHandler = async (req, res) => {
const token = await getToken({ req, secret })
if (!token) { // 🙂 handle no token
res.status(401)
}
const clientId = process.env.GOOGLE_CLIENT_ID
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
const accessToken = token?.accessToken as string | undefined
const auth = new google.auth.OAuth2({
clientId,
clientSecret,
})
auth.setCredentials({
access_token: accessToken,
})
const sheets = google.sheets({ version: "v4", auth })
const range = `${process.env.CATEGORIES_SHEET_NAME ?? "Categories"}!A2:B`
const response = await sheets.spreadsheets.values.get
({
spreadsheetId: process.env.SHEET_ID,
range,
})
res.json(response.data)
}
export default CategoriesHandler
Let’s see if this works by creating a custom hook leveraging the great react-query library:
// src/hooks/useCategories.ts
import toast from "react-hot-toast"
import { useQuery } from "react-query"
type Category = [string, string]
type SuccessfulResponse = {
range: string
majorDimension: string
values: Category[]
}
export const useCategories = () => {
return useQuery<SuccessfulResponse>(
"categories", () => fetch("/api/categories").then(res => res.json()), {
refetchOnWindowFocus: false,
onError: () => {
toast.error("Something went wrong 🙂")
},
})
}
In the beginning, we have type definitions we will not bother with, I simply looked them up. Notice that inside the react-query fetching function we send a GET request to the address “/api/categories” that matches the file we’ve just created in the step above.
In the request configuration, we choose not to re-fetch it on window focus (which is something react-query does by default) and to display an error toast message if the request didn’t succeed.
Now, let’s create an “Expense.tsx” component which will feature a form that will collect all the data we need to submit an expense. Mine looks like this:
// src/components/Expense.tsx
import React from "react"
import { motion } from "framer-motion"
import { ExpenseForm } from "./ExpenseForm"
export const Expense = () => {
return (
<motion.div
className="w-full h-full flex items-center justify-center"
animate={{ scale: [0.9, 1], opacity: [0, 1] }}
transition={{ duration: 0.5 }}>
<section className="ring-4 ring-green-400 rounded flex flex-col w-full max-w-md bg-white shadow-xl">
<div className="w-full flex justify-center px-4 py-4 bg-green-400 text-white border-b-2 border-green-200">
<span className="font-bold text-lg text-center">Expense</span>
</div>
<div className="px-4 py-4 h-full"
>
<ExpenseForm />
</div>
</section>
</motion.div>
)
}
It includes some basic Tailwind CSS styling and a simple animation built with framer-motion. Let’s go a bit further into “ExpenseForm.tsx” and see what’s there.
We will begin with creating a component that will use the data fetched in the “useCategories” hook:
import { useCategories } from "../hooks/useCategories"
export const ExpenseForm = () => {
const { data } = useCategories()
...
}
Then, let’s express what our form fields will look like as a type and initialize it with react-hook-form:
...
type ExpenseFormFields = {
category: string
amount: number
date: Date
description: string
}
export const ExpenseForm = () => {
const { data } = useCategories()
const {
register,
handleSubmit,
} = useForm<ExpenseFormFields>()
...
}
Then, let’s add the JSX for the form fields:
...
const onSubmit = (data: ExpenseFormFields) => {
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}
>
<label>
Amount
<input {...register("amount", { required: true }
)} type="number" placeholder="1$" />
</label>
<label>
Date
<input {...register("date")} type="date" />
</label>
<label>
Category
<select {...register("category")}>
{data?.values?.map(([id, category]) => (
<option key={id} value={category}>
{category}
</option>
))}
</select>
</label>
<label>
Description
<input {...register("description")} type="text" placeholder="A cupcake" />
</label>
<button type=”submit”>Add</button>
</form>
)
So we have four fields, out of which only one is required – the amount. This is a purely personal choice and you are free to make your own.
The part that I want you to focus on is this:
// ...
<label>
Category
<select {...register("category")}>
{data?.values?.map(([id, category]) => (
<option key={id} value={category}>
{category}
</option>
))}
</select>
</label>
If successfully fetched, the data should be filled with our categories which we can map over and create select <option>s from.
This form should be good enough for a user experience, but if you want to add some extra ✨, you can follow my work a bit more closely:
// src/components/ExpenseForm.tsx
import React from "react"
import { useForm } from "react-hook-form"
import { useCategories } from "../hooks/useCategories"
import { Button } from "./Button"
import { Field } from "./Field"
export const ExpenseForm = () => {
const { data } = useCategories()
const {
register,
handleSubmit,
reset,
formState: { isValid },
} = useForm<ExpenseFormFields>({
mode: "onChange",
})
const onSubmit = (data: ExpenseFormFields) => {
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-10 h-full">
<div className="flex flex-col gap-4">
<Field label="Amount" required>
<input
{...register("amount", { required: true })}
className="text-black py-4"
type="number"
placeholder="1$"
/>
</Field>
<Field label="Date">
<input {...register("date")} className="text-black py-4" type="date" />
</Field>
<Field label="Category">
<select {...register("category")} className="text-black py-4">
{data?.values?.map(([id, category]) => (
<option key={id} value={category}>
{category}
</option>
))}
</select>
</Field>
<Field label="Description">
<input
{...register("description")}
className="text-black py-4"
type="text"
placeholder="A cupcake"
/>
</Field>
</div>
<div className="grid grid-cols-4 gap-2"
>
<div className="col-span-3">
<Button disabled={!isValid} className="py-4 w-full"> {/* 🙂 disabling the button if the form is invalid; notice the “onChange” form mode */}
Add
</Button>
</div>
<Button onClick={() => reset()} variant="secondary">
Reset {/* 🙂 resetting the form state */}
</Button>
</div>
</form>
)
}
So far, we only console.log the data, but we want to add it to our “Expenses” sub sheet. To do that, we must head back to the server-side realm and create an expense handler:
// pages/api/expense.ts
import { google } from "googleapis"
import type { NextApiHandler } from "next"
import { getToken } from "next-auth/jwt"
const secret = process.env.SECRET
const ExpenseHandler: NextApiHandler
= async (req, res) => {
const token = await getToken({ req, secret })
if (!token) {
res.status(401)
}
const { body } = req
const clientId = process.env.GOOGLE_CLIENT_ID
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
const accessToken = token?.accessToken as string | undefined
const auth = new google.auth.OAuth2({
clientId,
clientSecret,
})
auth.setCredentials({
access_token: accessToken ?? "",
})
const sheets = google.sheets({ version: "v4", auth })
const range = `${process.env.EXPENSES_SHEET_NAME ??
"Expenses"}!A1:C1`
...
}
Looks familiar, huh? Only this time, we want to execute this logic if the method of the request is equal to “POST”:
// ...
const ExpenseHandler: NextApiHandler
= async (req,
res) => {
if (req.method === "POST") {
// continue
} else {
res.status(404)
}
Heading back to Google APIs calls, we have:
// ...
const range = `${process.env.EXPENSES_SHEET_NAME ?? "Expenses"}!A1:C1`
const response = await sheets.spreadsheets.values.append({
spreadsheetId: process.env.SHEET_ID,
range,
valueInputOption: "USER_ENTERED",
requestBody: {
values: [[“???date???”, “???category???”, “???description???”, “???amount???”]],
},
})
In the range, we can specify just the first row of our table (“A1:C1”), the API will be clever enough to append the data, but not overwrite it. The “valueInputOption” setting is something we don’t really have to bother with, “spreadsheetId” we already know, so that leaves us with the “requestBody” part.
This is where we pass the expense we just submitted with our react-hook-form. Our form returns an object with 4 keys and we need to transform it into an array of 4 values. We will pass our form object in the body of the request and then unpack it this way:
// ...
const { body } = req
const parsedBody = JSON.parse(body)
const values = [[parsedBody.date, parsedBody.category, parsedBody.description, parsedBody.amount]]
And onto the last step:
// ...
const response = await sheets.spreadsheets.values.append({
spreadsheetId: process.env.SHEET_ID,
range,
valueInputOption: "USER_ENTERED",
requestBody: {
values,
},
})
res.json(response.data)
Which wraps up the process of writing the expense handler:
// pages/api/expense.ts
import { google } from "googleapis"
import type { NextApiHandler } from "next"
import { getToken } from "next-auth/jwt"
const secret = process.env.SECRET
const ExpenseHandler: NextApiHandler
= async (req, res) => {
if (req.method === "POST") {
const token = await getToken({ req, secret })
if (!token) {
res.status(401)
}
const clientId = process.env.GOOGLE_CLIENT_ID
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
const accessToken = token?.accessToken as string | undefined
const auth = new google.auth.OAuth2({
clientId,
clientSecret,
})
auth.setCredentials({
access_token: accessToken ?? "",
})
const sheets = google.sheets({ version: "v4", auth })
const range = `${process.env.EXPENSES_SHEET_NAME ?? "Expenses"}!A1:C1`
const { body } = req
const parsedBody = JSON.parse(body)
const values = [[parsedBody.date, parsedBody.category, parsedBody.description, parsedBody.amount]]
const response = await sheets.spreadsheets.values.append({
spreadsheetId: process.env.SHEET_ID,
range,
valueInputOption: "USER_ENTERED",
requestBody: {
values,
},
})
res.json(response.data)
} else {
res.status(404)
}
}
export default ExpenseHandler
The only part that’s missing is the request execution on the client-side, which we will cover with the “useMutation” hook from react-query:
// src/components/ExpenseForm.tsx
// ...
const { mutate } = useMutation(
"expense",
(values: ExpenseFormFields) =>
fetch("/api/expense", {
method: "POST",
body: JSON.stringify(values),
}).then(res => res.json()),
{
onSuccess: () => {
toast.success("Added an expense! 🙂")
},
onError: () => {
toast.error("Something went wrong 🙂")
},
}
)
Then, we have to fire the mutation on the submit event:
// ...
const onSubmit = (data: ExpenseFormFields) => {
mutate(data)
}
The logic combined with my custom components and Tailwind CSS styles, ends up looking like this:
// src/components/ExpenseForm.tsx
import React from "react"
import { useForm } from "react-hook-form"
import toast from "react-hot-toast"
import { useMutation } from "react-query"
import { useCategories } from "../hooks/useCategories"
import { Button } from "./Button"
import { Field } from "./Field"
export type ExpenseFormFields = {
category: string
amount: number
date: Date
description: string
}
export const ExpenseForm = () => {
const { data } =
useCategories()
const {
register,
handleSubmit,
reset,
formState: { isValid },
} = useForm<ExpenseFormFields>({
mode: "onChange",
})
const { isLoading, mutate } = useMutation(
"expense",
(values: ExpenseFormFields) =>
fetch("/api/expense", {
method: "POST",
body: JSON.stringify(values),
}).then(res => res.json()),
{
onSuccess: () => {
toast.success("Added an expense! 🙂")
},
onError: () => {
toast.error("Something went wrong 🙂")
},
}
)
const onSubmit = (data: ExpenseFormFields) => {
mutate(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-10 h-full">
<div className="flex flex-col gap-4">
<Field label="Amount" required>
<input
{...register("amount", { required: true })}
className="text-black py-4"
type="number"
placeholder="1$"
/>
</Field>
<Field label="Date">
<input {...register("date")} className="text-black py-4" type="date" />
</Field>
<Field label="Category">
<select {...register("category")} className="text-black py-4">
{data?.values?.map(([id, category]) => (
<option key={id} value={category}>
{category}
</option>
))}
</select>
</Field>
<Field label="Description">
<input
{...register("description")}
className="text-black py-4"
type="text"
placeholder="A cupcake"
/>
</Field>
</div>
<div className="grid grid-cols-4 gap-2">
<div className="col-span-3">
<Button disabled={!isValid} className="py-4 w-full">
{isLoading ? "Loading..." : "Add"}
</Button>
</div>
<Button onClick={() => reset()} variant="secondary">
Reset
</Button>
</div>
</form>
)
}
If all goes well, rendering this code should allow you to witness a process similar to mine:
5. Deployment
My deployment service of choice has always been Vercel and I recommend you consider it as well, especially when deploying a Next.js application (the framework was created by Vercel). The process of publishing this app is nothing but joy, so I will not spoil the surprise for you.
However, I must remind you to include your environment variables in the CI/CD as well as add the deployment address to “Authorized domains” in your Google Cloud OAuth settings.
I hope you had a good time learning the fundamental features of Next.js as well as bending Google APIs to your will. I hope this combination will serve you well and I’ll leave you with a bunch of feature ideas that you can implement in your budgeting application:
- Adding multiple expenses at once
- Shared access to the app
- Storing user settings (like sub sheet names)
- Gamification effects for successfully adding an impressive number of expenses (a confetti explosion will suffice for starters)
Summary
This app does not aim to compete with a sophisticated personal budgeting product. It is born of my own need for a more convenient way of logging my own expenses and providing myself with a better user experience when interacting with Google Sheets, the tool which I consider good enough for serving as both the database and the backend of the app.
Depending on one’s will and needs, the app can be further developed to provide the user with a quick view of charts or notifications about overspending (for Legos or anything else). Yet my personal goal of making the process of filling the sheet with expenses more seamless and user-friendly has been fully achieved. Hope you like it too!