スプレッドシートをDB代わりに活用してみたいと思い、スプレッドシートとNext.jsを連携したサイトを作ってみました。
まだまだNext.jsのバージョン15に関する資料が少ないと感じているので、少しでも参考になれば。
個人開発していると、規模的にSupabaseとか使うまででもないんだよなー、スプレッドシートでいいんだよなーという場面があったのと、どうしてみんなしないんだろう?という疑問があったので自己学習兼ねてやってみました。
利用用途
- GASで取得してスプレッドシートに入れているデータをローカルサーバーから確認する
- 顧客管理システムの作成にもつながる
完成形
UI
日付の選択
日付選択後のイメージ
スプレッドシート側のイメージ
使用技術
- Next.js: 15.1.6
- Typescript
- Google apps script
開発の実際
Next.jsの立ち上げ
npx create-next-app@latest
でプロジェクト立ち上げましょう。基本はRecommendの通り選択して大丈夫です。
簡単なUIの作成
スプレッドシート側のデータを受けて出力させるページを用意します。今回はshadcn/uiを利用しています。利用したコンポーネントTabs, Caledar, Card, Button, Popover
になります。
Hooksを使わないシンプルなものをこの段階では用意しました。
import { useEffect, useState } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Calendar } from "@/components/ui/calendar";
import * as React from "react"
import { format } from "date-fns"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export default function Home() {
return (
<div className="">
<h1 className="text-4xl font-bold text-center py-10">CSRで取得する実績一覧</h1>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
>
<span>Pick a date</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
//onSelect={(selectedDate: Date | undefined) => setDate(selectedDate)}
initialFocus
/>
</PopoverContent>
</Popover>
{/* タブグループで分ける */}
<Tabs defaultValue="account" className="w-[1080px] mx-auto mt-12">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="account">施設</TabsTrigger>
<TabsTrigger value="password">店舗</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader>
</CardHeader>
<CardContent className="space-y-2">
</CardContent>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader>
<CardContent className="space-y-2">
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
スプレッドシート側の準備
施設データというシートに対して、ヘッダー行があってそこからデータを取得してくることを考えています。
GAS(Typescript)で組んでウェブアプリにして最終的にNext.js側でFetchするようにします。
const fileId = PropertiesService.getScriptProperties().getProperty("THIS_FILE_ID");
const clinicSheetName = "施設データ";
const shopSheetName = "店舗データ";
function doGet(e: any){
const dateParam = e.parameter.date;
if(!dateParam){
return ContentService.createTextOutput(JSON.stringify({error: "日付指定なし"})).setMimeType(ContentService.MimeType.JSON);
}
if(!fileId) {
return ContentService.createTextOutput(JSON.stringify({error: "ファイルが見つかりませんでした"})).setMimeType(ContentService.MimeType.JSON);
}
const ss = SpreadsheetApp.openById(fileId);
const clinicData = ss.getSheetByName(clinicSheetName)?.getDataRange().getDisplayValues()!;
const clinicHeaders: string[] = clinicData[0];
const clinicResult = [];
for (let i = 0; i < clinicData.length; i++){
const row: string[] = clinicData[i];
const rowData: {[key: string]: string} = {};
for (let j = 0; j < clinicHeaders.length; j++){
rowData[clinicHeaders[j]] = row[j];
}
if(rowData["日付"] === dateParam){
clinicResult.push(rowData)
}
}
return ContentService.createTextOutput(JSON.stringify({
date: dateParam,
records: clinicResult
})).setMimeType(ContentService.MimeType.JSON);
}
取得したデータをJSON形式にして返します。
デプロイ>ウェブアプリにしてできたURLをコピーしておきます。
Next.js側でのFetch処理
必要となるのは、日付情報としての[date, setDate]
と得られたデータの加工のための[data, setData]
になります。
ここで自分の学びになったのは、状態管理のuseSateを使う時には初期値の設定だけじゃなくてしっかり型定義しておくことが必要ということでした。そりゃそうだよね。
最終的なコード
"use client";
import { useEffect, useState } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Calendar } from "@/components/ui/calendar";
import * as React from "react"
import { format } from "date-fns"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
// データの型定義
interface Records {
日付: string,
施設名: string,
担当名: string,
セル値: string,
メニュー: string,
施設売上: string,
}
interface ApiResponse {
records: Records[]
}
export default function Home() {
const [data, setData] = useState<Records[]>([]);
const [date, setDate] = useState<Date | undefined>(new Date())
const webAppUrl: string = process.env.NEXT_PUBLIC_WEBAPP_URL!;
console.log("webAppUrl");
useEffect(() => {
if(!date) return;
const formattedDate = format(date, "yyyy年MM月dd日");
console.log(`${webAppUrl}?date=${formattedDate}`);
fetch(`${webAppUrl}?date=${formattedDate}`)
.then((res) => res.json())
.then((json) => {
if(json.records){
setData(json.records)
} else {
setData([])
}
})
.catch((err) => console.error("データ取得エラー:", err))
}, [date])
return (
<div className="">
<h1 className="text-4xl font-bold text-center py-10">CSRで取得する実績一覧</h1>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-[480px] justify-center text-center font-normal flex mx-auto",
!date && "text-muted-foreground"
)}
>
{date ? format(date, "yyyy年MM月dd日") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={(selectedDate: Date | undefined) => setDate(selectedDate)}
initialFocus
/>
</PopoverContent>
</Popover>
{/* タブグループで分ける */}
<Tabs defaultValue="account" className="w-[1080px] mx-auto mt-12">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="account">施設</TabsTrigger>
<TabsTrigger value="password">店舗</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader>
<CardTitle>{date ? format(date, "yyyy年MM月dd日の施設実績") : <span>日時を選択してください</span>}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{data.length > 0 ? (
<table className="w-full">
<tr className="font-bold">
<td>施設名</td>
<td>担当名</td>
<td>メニュー</td>
<td>施設売上</td>
</tr>
{data.map((item, index) => (
<>
<tr key={index}>
<td>{item["施設名"]}</td>
<td>{item["担当名"]}</td>
<td>{item["メニュー"]}</td>
<td>{item["施設売上"]}</td>
</tr>
</>
))}
</table>
) : (
<p>該当データがありません</p>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader>
<CardTitle>{date ? format(date, "yyyy年MM月dd日") : <span>未選択</span>}の店舗実績</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
これでカレンダーを選択するとその日付情報に応じたデータが出力されるようになります。
やってみた感想
実際にデータが取得できたのは感動しました。おおー連携できた!みたいな。
でも何回かやっていると、処理の遅さが気になる。CSRで都度とっているのと、GAS側の処理のスピードが絡んでいるんだと思う。
だから先人たちはSupabaseとかFirebaseとかの利用を促すのねーと感心。
でもデータテーブル作ってマイグレーションして、Prismaいれて…という一連の流れを考えたら今回の実装の方が簡単ではあった(と思う)。
今後やりたいこと
今回は施設データと店舗データという2種のデータを用意しているので、タブの切り替えでこれができるようにしたい。