Introduction
In this tutorial, we will learn how to store user input from a Next.js app into a Google Sheets spreadsheet.
Requirements
- Google Sheets Setup
- A running Next.js App
I will start with a clean project, but I assume you already have a Next.js app up and running. Below is what I have in my layout.tsx, globals.css, and home page files:
// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Nextjs Google Sheets Integration",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className="antialiased"
>
{children}
</body>
</html>
);
}
/* app/global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
// app/page.tsx
export default function Home() {
return (
<main>
<h1>Nextjs Google Sheets Integration</h1>
</main>
);
}
Now let's setup google so we can use it as our backend for our subscribers.
Google Setup
We will first need to create a new Service Account to do so go here
Click on Start Free
Read the terms of service and click Agree & Continue.
- Create your Payment Profile (you won't be charged).
- Select Individual Profile Type if you're not a business.
- Click Create.
- Now add a payment method.
- Click Start Free.
- Select your Cloud Platform options.
Great! Now that we have a Service Account set up, look for the menu on the left and click on APIs & Services.
Enable APIs & Services
Search for Google Sheets API and select it
Click Enable
Great now we have the google sheets API enabled now let's create the credentials so we can programmatically connect to it.
Click on Create Credentials
Select Application Data & click Done
Now click on the IAM & Admin in the left menu and then click Service Accounts
Now click on Create Service Account
Give your account a name and a description and click Create And Continue
Select Owner as the Role and click Continue
We can click Done on the final step.
Now we can manage the keys for this account which will allow us to connect to Google Sheets.
Click on the 3 dots and select Manage Keys
Click on Add Key and then on Create new key
Select the JSON option and click Create
Save it somewhere safe and then open it in your code editor
Great all we really care about are the private_key and the client_email, these will allow us to establish the connection.
Go back to your Nextjs Project and in the .env.local (if you don't have a .env file yet create one in the root of your project) file let's create the environment variables we will need.
# .env.local
GOOGLE_SERVICE_ACCOUNT_EMAIL=
GOOGLE_PRIVATE_KEY=
Paste your keys from the JSON file in here.
Awesome! Now we are ready to start writing some code finally!
Let's run our app
pnpm dev
And open it in the browser.
We are going to create a button that opens a Dialog with the subscription form.
Lets start by adding the SubscribeDialog component.
Create Subscribe.tsx and import it in your page.tsx
We'll copy some basic styles for the buttons from tailwindcss.com
We will also use a dialog element so we can take advantage of some default behaviour that we'll see in action later, we have to give it the open property so we can see it on the screen, and some basic layout styles.
If we remove the open prop you will see the content change.
// app/Subscribe.tsx
const Subscribe = () => {
return (
<>
<button className="bg-transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent rounded">
Subscribe
</button>
<dialog open className="border border-black py-5 px-3">
<form className="flex flex-col gap-2">
<label htmlFor="email">
<span>Email: </span>
<input type="email" id="email" name="email" className="border border-black" />
</label>
<button type="submit" className="transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent rounded">Subscribe</button>
</form>
</dialog>
</>
);
}
export default Subscribe;
Call it in page.tsx
// app/page.tsx
import Subscribe from "./Subscribe";
export default function Home() {
return (
<main className="flex flex-col items-center justify-center min-h-[100dvh]">
<h1>Nextjs Google Sheets Integration</h1>
<Subscribe />
</main>
);
}
Now we can add the functionality to the button so we can open and close the dialog.
We will need to use the useRef hook which means we will need to make this component into a client component.
We will also write a toggleDialog function so we can open and close the dialog, and we will also add a close button to the dialog so we can close it. However because we are using the dialog and the showModal javascript function, it means we can use the ESC key to close the modal as well.
// app/Subscribe.tsx
"use client";
import { useRef } from "react";
const Subscribe = () => {
const dialogRef = useRef<HTMLDialogElement>(null);
const toggleDialog = () => {
if (dialogRef.current?.open) {
return dialogRef.current?.close();
}
dialogRef.current?.showModal();
}
return (
<>
<button onClick={toggleDialog} className="bg-transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent">
Subscribe
</button>
<dialog ref={dialogRef} className="border border-black py-10 px-3 backdrop:bg-black backdrop:opacity-70">
<form className="flex flex-col gap-2">
<button onClick={toggleDialog} className="absolute right-2 top-2">X</button>
<label htmlFor="email">
<span>Email: </span>
<input type="email" id="email" name="email" className="border border-black" />
</label>
<button type="submit" className="transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent">Subscribe</button>
</form>
</dialog>
</>
);
}
export default Subscribe;
Nice! But we also want to capture more than the users email, however in my case I just want first name and last name.
// app.Subscribe.tsx
"use client";
import { useRef } from "react";
const Subscribe = () => {
const dialogRef = useRef<HTMLDialogElement>(null);
const toggleDialog = () => {
if (dialogRef.current?.open) {
return dialogRef.current?.close();
}
dialogRef.current?.showModal();
}
return (
<>
<button onClick={toggleDialog} className="bg-transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent">
Subscribe
</button>
<dialog ref={dialogRef} className="border border-black pt-10 pb-5 px-3 backdrop:bg-black backdrop:opacity-70">
<form className="flex flex-col gap-2">
<button onClick={toggleDialog} className="absolute right-2 top-2">X</button>
<label htmlFor="email">
<span>Email: </span>
</label>
<input type="email" id="email" name="email" className="border border-black" />
<label htmlFor="firstName">
<span>First Name: </span>
</label>
<input type="firstName" id="firstName" name="firstName" className="border border-black" />
<label htmlFor="lastName">
<span>Last Name: </span>
</label>
<input type="lastName" id="lastName" name="lastName" className="border border-black" />
<button type="submit" className="transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent">Subscribe</button>
</form>
</dialog>
</>
);
}
export default Subscribe;
Great! Now that we have a front end let's write a form action for us to safely store the user's information on the backend. We will use valibot to validate the user data and the google API package to store the data.
Let's add the googleapis package first.
pnpm add googleapis
Create the action file, I'm going to call it subscribeAction, which will receive our form data and store it, so let's start by receiving the form data. To make it safe we will use a "use server" directive at the top of the file.
// app/subscribeAction.ts
"use server";
export const subscribeAction = async (formData: FormData) => {
const rawData = {
email: formData.get("email"),
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
}
console.log({rawData})
};
And we have to call our function, in our form like this:
// app/Subscribe.ts
import { subscribeAction } from "./subscribeAction";
// ... rest of your code
<form className="flex flex-col gap-2" action={subscribeAction}>
// ... rest of your code
Now you should be able to see the console log when you click the subscribe button.
Awesome let's add valibot now so we can validate the data. (valibot is a smaller zod which will help you add types and validations and errors to show) valibot uses a slightly different approach than zod, if we want to run different validations on a single piece of data we need to create pipes, and then we can add any validations we need. Feel free to go to their docs to learn more.
Let's do that first
pnpm add valibot
It's best to write your validations in a separate file in case you need to call the types on the front-end. I'm going to call this file valibotSchemas.ts.
// app/valibotSchemas.ts
import { email, maxLength, nonEmpty, object, pipe, string } from "valibot";
export const subscribeDataSchema = object({
email: pipe(
string(),
nonEmpty("Please enter your email."),
email("The email is badly formatted."),
maxLength(30, "Your email is too long.")
),
firstName: pipe(
string(),
nonEmpty("Please enter your first name."),
maxLength(30, "Your first name is too long.")
),
lastName: pipe(
string(),
nonEmpty("Please enter your last name."),
maxLength(30, "Your first name is too long.")
),
});
Now we can import it so we can validate our data and get sweet type safety too.
// app/subscribeAction.ts
"use server";
import { parse, ValiError } from "valibot";
import { subscribeDataSchema } from "./valibotSchemas";
export const subscribeAction = async (formData: FormData) => {
const rawData = {
email: formData.get("email"),
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
};
try {
const data = parse(subscribeDataSchema, rawData);
console.log({ data });
} catch (error: unknown) {
if (error instanceof ValiError) {
const issues = error.issues;
const errorMessages = issues.map((issue) => issue.message);
console.log({ errorMessages });
} else {
console.log({ error });
}
}
};
Now if you an empty value on your form and click subscribe and look at your console you will see the error messages, we will use these to let the user know if there is an issue. To do this we will use React's useFormState hook.
We will add the useFOrmState and replace the action in our form in our Subscribe component.
"use client";
import { useRef } from "react";
import { subscribeAction } from "./subscribeAction";
import { useFormState } from "react-dom";
const Subscribe = () => {
const [formState, formAction] = useFormState(subscribeAction, {
success: false,
errors: null,
});
const dialogRef = useRef<HTMLDialogElement>(null);
const toggleDialog = () => {
if (dialogRef.current?.open) {
return dialogRef.current?.close();
}
dialogRef.current?.showModal();
}
return (
<>
<button onClick={toggleDialog} className="bg-transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent">
Subscribe
</button>
<dialog ref={dialogRef} className="border border-black pt-10 pb-5 px-3 backdrop:bg-black backdrop:opacity-70">
<form className="flex flex-col gap-2" action={formAction}>
<button onClick={toggleDialog} className="absolute right-2 top-2">X</button>
<label htmlFor="email">
<span>Email: </span>
</label>
<input type="email" id="email" name="email" className="border border-black" />
<label htmlFor="firstName">
<span>First Name: </span>
</label>
<input type="firstName" id="firstName" name="firstName" className="border border-black" />
<label htmlFor="lastName">
<span>Last Name: </span>
</label>
<input type="lastName" id="lastName" name="lastName" className="border border-black" />
<button type="submit" className="transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent">Subscribe</button>
</form>
</dialog>
</>
);
}
export default Subscribe;
Now typescript is unhappy with out subscribe function because the action now needs to receive the formState, and also the returns of our function don't match the expected return defined in our form action ({ success: false, errors: null}) so let's add fix that.
Since we are not going to use we can just leave it empty.
// app/subscribeAction.ts
"use server";
import { parse, ValiError } from "valibot";
import { subscribeDataSchema } from "./valibotSchemas";
export const subscribeAction = async (state: {}, formData: FormData) => {
const rawData = {
email: formData.get("email"),
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
};
try {
const data = parse(subscribeDataSchema, rawData);
console.log({ data });
return {
success: true,
errors: null,
};
} catch (error: unknown) {
if (error instanceof ValiError) {
const issues = error.issues;
const errorMessages = issues.map((issue) => issue.message);
console.log({ errorMessages });
return {
success: false,
errors: errorMessages,
};
} else {
console.log({ error });
return {
success: false,
errors: ["An error occurred."],
};
}
}
};
And now on our Subscribe component let's render those messages if we have any.
// app/Subscribe.tsx
"use client";
import { useRef } from "react";
import { subscribeAction } from "./subscribeAction";
import { useFormState } from "react-dom";
const Subscribe = () => {
const [formState, formAction] = useFormState(subscribeAction, {
success: false,
errors: null,
});
const dialogRef = useRef<HTMLDialogElement>(null);
const toggleDialog = () => {
if (dialogRef.current?.open) {
return dialogRef.current?.close();
}
dialogRef.current?.showModal();
}
return (
<>
<button onClick={toggleDialog} className="bg-transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent">
Subscribe
</button>
<dialog ref={dialogRef} className="border border-black pt-10 pb-5 px-3 backdrop:bg-black backdrop:opacity-70">
<form className="flex flex-col gap-2" action={formAction}>
<button onClick={toggleDialog} className="absolute right-2 top-2">X</button>
<label htmlFor="email">
<span>Email: </span>
</label>
<input type="email" id="email" name="email" className="border border-black" />
<label htmlFor="firstName">
<span>First Name: </span>
</label>
<input type="firstName" id="firstName" name="firstName" className="border border-black" />
<label htmlFor="lastName">
<span>Last Name: </span>
</label>
<input type="lastName" id="lastName" name="lastName" className="border border-black" />
<button type="submit" className="transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent">Subscribe</button>
{formState.errors?.map((error) => (
<span className="text-red-700">
*{error}
</span>
))}
</form>
</dialog>
</>
);
}
export default Subscribe;
Great! Now that we have dealt with the errors let's work on storing our data on a google spreadsheet. We have not yet created one so let's go ahead and do that. Go to Google Sheets and Create a new Blank Spreadsheet
Let's add titles to the first row, in my case A will be email, B will be first name, and C will be last name.
On the spreadsheet for ease of reading later.
Lastly, we will add the google Service Account email to the users allowed to edit the document. Click on Share.
Add the Service Email we added to the .env.local and give it editor access.
Click Send
We will need this spreadsheet's id in order to connect to it.
The id of the spreadsheet is in the URL, it is everything between /d/ and /edit rest of URL.
..../spreadsheets/d/<>/edit......
Copy it and let's head back to our .env.local and add it in there. I will call this environment variable GOOGLE_SPREADSHEET_ID. Remember to restart your server after adding anything to your .env.local.
Now we can create a function that will return the google auth so we can use it in other places too.
Let's create a new file for that, I will call it googleAuth.ts
// app/googleAuth.ts
import { google } from "googleapis";
export const googleAuth = async () => {
const clientEmail = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
const privateKey = process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, "\n");
if (!clientEmail || !privateKey) {
throw new Error("Missing Google credentials.");
}
return new google.auth.GoogleAuth({
credentials: {
client_email: clientEmail,
private_key: privateKey,
},
scopes: [
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/spreadsheets",
],
});
};
Great now let's update our action and finally store some data on our sheet.
// app/subscribeAction.ts
"use server";
import { parse, ValiError } from "valibot";
import { subscribeDataSchema } from "./valibotSchemas";
import { googleAuth } from "./googleSpreadsheets";
import { google } from "googleapis";
export const subscribeAction = async (state: {}, formData: FormData) => {
const spreadsheetId = process.env.GOOGLE_SPREADSHEET_ID;
if (!spreadsheetId) {
return {
success: false,
errors: ["There was an error connecting to google."],
};
}
const rawData = {
email: formData.get("email"),
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
};
try {
const data = parse(subscribeDataSchema, rawData);
const sheets = await google.sheets({
auth: await googleAuth(),
version: "v4",
});
await sheets.spreadsheets.values.append({
spreadsheetId,
range: "A1:C1",
valueInputOption: "USER_ENTERED",
requestBody: {
values: [[data.email, data.firstName, data.lastName]],
},
});
return {
success: true,
errors: null,
};
} catch (error: unknown) {
if (error instanceof ValiError) {
const issues = error.issues;
const errorMessages = issues.map((issue) => issue.message);
return {
success: false,
errors: errorMessages,
};
} else {
console.error(error);
return {
success: false,
errors: ["An error occurred."],
};
}
}
};
Now check your spreadsheet and make sure you were able to add the information correctly. If you get any errors make sure your environment variables don't have any typos.
If you get an unauthorized message you might have missed the sharing the spreadsheet with the service account step. Go back to the Google Setup section.
Awesome, now that we are storing data, we want to make sure we don't add any repeats and that we get a success message if the information was added correctly.
Let's work on the no email repeats part first.
// app/subscribeAction.ts
"use server";
import { parse, ValiError } from "valibot";
import { subscribeDataSchema } from "./valibotSchemas";
import { googleAuth } from "./googleAuth";
import { google } from "googleapis";
export const subscribeAction = async (state: {}, formData: FormData) => {
const spreadsheetId = process.env.GOOGLE_SPREADSHEET_ID;
if (!spreadsheetId) {
return {
success: false,
errors: ["There was an error connecting to google."],
};
}
const rawData = {
email: formData.get("email"),
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
};
try {
const data = parse(subscribeDataSchema, rawData);
const sheets = await google.sheets({
auth: await googleAuth(),
version: "v4",
});
// Select spreadsheet the range to read my emails are on column A
const readRange = "A1:A";
// Get the emails from the spreadsheet
const emails = await sheets.spreadsheets.values.get({
spreadsheetId: process.env.GOOGLE_SPREADSHEET_ID,
range: readRange,
});
// Check if the email already exists
const emailExists = emails.data.values?.flat().includes(data.email);
if (emailExists) {
return {
success: false,
errors: ["You're already subscribed!"],
};
};
await sheets.spreadsheets.values.append({
spreadsheetId,
range: "A1:C1",
valueInputOption: "USER_ENTERED",
requestBody: {
values: [[data.email, data.firstName, data.lastName]],
},
});
return {
success: true,
errors: null,
};
} catch (error: unknown) {
if (error instanceof ValiError) {
const issues = error.issues;
const errorMessages = issues.map((issue) => issue.message);
return {
success: false,
errors: errorMessages,
};
} else {
return {
success: false,
errors: ["An error occurred."],
};
}
}
};
Great now we should be able to see a message if the user is already in our spreadsheet.
Now let's add a small success message.
// app/Subscribe.tsx
"use client";
import { useRef } from "react";
import { subscribeAction } from "./subscribeAction";
import { useFormState } from "react-dom";
const Subscribe = () => {
const [formState, formAction] = useFormState(subscribeAction, {
success: false,
errors: null,
});
const dialogRef = useRef<HTMLDialogElement>(null);
const toggleDialog = () => {
if (dialogRef.current?.open) {
return dialogRef.current?.close();
}
dialogRef.current?.showModal();
}
return (
<>
<button onClick={toggleDialog} className="bg-transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent">
Subscribe
</button>
<dialog ref={dialogRef} className="border border-black pt-10 pb-5 px-3 backdrop:bg-black backdrop:opacity-70">
{formState.success ? (
<div>
<button onClick={toggleDialog} className="absolute right-2 top-2">X</button>
<h2>Thank you for subscribing!</h2>
</div>
) : (
<form className="flex flex-col gap-2" action={formAction}>
<button onClick={toggleDialog} className="absolute right-2 top-2">X</button>
<label htmlFor="email">
<span>Email: </span>
</label>
<input type="email" id="email" name="email" className="border border-black" />
<label htmlFor="firstName">
<span>First Name: </span>
</label>
<input type="firstName" id="firstName" name="firstName" className="border border-black" />
<label htmlFor="lastName">
<span>Last Name: </span>
</label>
<input type="lastName" id="lastName" name="lastName" className="border border-black" />
<button type="submit" className="transparent hover:bg-green-500 text-green-700 font-semibold hover:text-white py-2 px-4 border border-green-500 hover:border-transparent">Subscribe</button>
{formState.errors?.map((error) => (
<span className="text-red-700">
*{error}
</span>
))}
</form>
)}
</dialog>
</>
);
}
export default Subscribe;
And just like that, we're done! Now you know how to use Google Sheets to store data, which you could use as a lightweight CMS or even a simple database.
I hope you learned something valuable from this tutorial. Feel free to leave any comments or ask any questions!
Julian Bustos
Top comments (0)