MENU

問い合わせ


    【NuxtとGoogle Mapsで作る情報集約マップ 第3回】地点データをNuxt server API + SQLiteに保存する

    Google Maps API とNuxtで社内向けの情報集約マップを作る連載の第3回です。前回(第2回)では、ファイルに直書きしたサンプル20地点を、Advanced Markers でカテゴリ別の色付きピンとして表示しました。今回は地点データを Nuxt のサーバーAPIとSQLite(データベース) に移し、登録・更新・削除ができる土台を作ります。

    「地図アプリなのに、データベースやAPIの話が必要なの?Google Maps の中にデータを保存できないの?」

    結論から言うと、地点データは自社のデータベースに持つのが基本です。Google Maps は地図を「表示する」サービスで、社内の台帳データを保存する場所ではありません。自社データを自社のDBとAPIで持っておけば、画面の作り直しや、将来地図サービスを変える場合にもデータがそのまま使えます。この回は地図の見た目は変わりませんが、以降のすべての回の土台になります。

    目次

    この回で作るもの:地点データのサーバーAPI

    ブラウザの画面は、Nuxt のサーバー側に用意した API(/api/points)を呼び出してデータをやり取りします。

    メソッドとURL動き
    GET /api/points全地点を返す
    POST /api/points地点を1件登録(成功時 201)
    PUT /api/points/:id地点を更新(存在しなければ 404)
    DELETE /api/points/:id地点を削除(成功時 204)

    不正な値(緯度 999 やカテゴリ名の間違い)は、サーバー側で 400 エラーとして弾きます。画面の入力チェックは使い勝手のため、サーバー側のチェックは安全のため、と役割を分けて考えます。

    使う仕組み

    用語意味
    server APINuxt の server/api/ に置いたファイルが、そのままAPIのURLになる仕組み
    NitroNuxt のサーバー部分を動かしているエンジン
    db0 / useDatabase()Nitro から SQL データベースを扱うための仕組み。接続先(connector)を差し替えられる
    SQLiteファイル1つで動くデータベース。開発・試作に向く
    node:sqliteNode.js に組み込まれた SQLite。追加のネイティブモジュールのビルドが不要
    zod入力値の形式をチェックするライブラリ(この連載では zod 4)

    SQLite の接続には Node.js 組み込みの node:sqlite を使います。以前よく使われた better-sqlite3 はインストール時にネイティブモジュールのビルドが必要で、Windows・WSL・macOS など環境によってつまずきやすいためです。本番では PostgreSQL などに connector を差し替える前提で、SQL は db0 の書き方に統一しておきます(第12回で扱います)。

    実装:Nitro のデータベース機能を有効にする

    この回で追加・変更したファイルは次のとおりです。

    ファイル役割
    nuxt.config.tsデータベース機能の有効化と接続設定
    shared/types/point.ts地点の型(第2回の app/types/ から移動・拡張)
    server/plugins/db-init.ts起動時のテーブル作成とサンプル投入
    server/utils/pointSchema.tszod による入力検証
    server/utils/points.tsDBの行 → APIの形への変換
    server/api/points/*.ts4つのAPI
    app/pages/index.vueuseFetch でAPIから取得して表示

    手順1:データベース機能を有効にする

    nuxt.config.ts(抜粋)

      // 第3回: Nitro の SQL データベース機能(experimental)。
      // node:sqlite(Node.js 組み込み)を使う db0 の connector で .data/gmap.sqlite に保存する。
      nitro: {
        experimental: {
          database: true,
        },
        database: {
          default: {
            connector: 'node-sqlite',
            options: { name: 'gmap' },
          },
        },
      },

    📰 出典:Nitro「SQL Database」

    Nitro のデータベース機能は、執筆時点(2026年9月)でも experimental(実験的機能) の扱いです。設定は公式ガイドどおり experimental.database を有効にし、database に接続先を書きます。connector 名 node-sqlite は Nuxt 4 に同梱されている db0 に含まれていることを確認しました。name: 'gmap' にすると、起動したディレクトリの .data/gmap.sqlite にデータが保存されます。

    .data/ と *.sqlite は .gitignore 済みなので、開発中のデータがリポジトリに入ることはありません。

    📰 出典:Node.js 24「SQLite」

    手順2:型をサーバーと画面で共有する(shared/)

    第2回では地点の型を app/types/point.ts に置きましたが、サーバー側の入力チェックでも同じカテゴリ定義を使いたいので、shared/types/point.ts に移しました。Nuxt 4 の shared/ ディレクトリは、画面(Vue)とサーバー(Nitro)の両方から使うコードの置き場で、#shared/... で読み込めます。

    📰 出典:Nuxt「shared/ directory」

    shared/types/point.ts(追加部分の抜粋)

    // 座標の出どころ。Google 由来(geocoding / places)の座標は規約上の保存期間に注意(第6・7回)
    export const POINT_SOURCES = ['manual', 'geocoding', 'places', 'csv'] as const
    export type PointSource = (typeof POINT_SOURCES)[number]
    
    export interface Point {
      id: number
      name: string
      category: Category
      lat: number
      lng: number
      memo: string
      placeId: string | null // Google の place_id(第6・7回で使用)
      source: PointSource
      fetchedAt: string | null // Google から座標を取得した日時(第6・7回で使用)
      updatedAt: string // ISO 8601
    }

    placeId・source・fetchedAt はこの回では使いませんが、先に用意しておきます。Google Maps Platform の規約では、住所変換や施設検索で得た緯度経度には保存期間の制限がある一方、place_id は保存してよいとされています。「どこから来た座標か」を記録しておくことが、第6・7回で規約に沿った設計をするための前提になります。

    shared/ のコードは Vue やサーバー専用のコードを読み込めない、という制約があります。型と定数だけを置くようにしましょう。

    手順3:起動時にテーブルを用意する

    server/plugins/db-init.ts

    import { samplePoints } from '../data/sample-points'
    
    export default defineNitroPlugin(async () => {
      const db = useDatabase()
    
      await db.sql`CREATE TABLE IF NOT EXISTS points (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        category TEXT NOT NULL,
        lat REAL NOT NULL,
        lng REAL NOT NULL,
        memo TEXT NOT NULL DEFAULT '',
        place_id TEXT,
        source TEXT NOT NULL DEFAULT 'manual',
        fetched_at TEXT,
        updated_at TEXT NOT NULL
      )`
    
      const { rows } = await db.sql`SELECT COUNT(*) AS count FROM points`
      if (Number(rows?.[0]?.count ?? 0) > 0) return
    
      const now = new Date().toISOString()
      for (const p of samplePoints) {
        await db.sql`INSERT INTO points (name, category, lat, lng, memo, updated_at)
          VALUES (${p.name}, ${p.category}, ${p.lat}, ${p.lng}, ${p.memo ?? ''}, ${now})`
      }
    })

    サーバーの起動時にテーブルを作り、空なら第2回のサンプル20件(server/data/sample-points.ts に移動)を入れます。本番では、テーブル定義の変更履歴を管理できる専用のマイグレーション(=DB構造の変更手順)の仕組みに置き換えるのが一般的です。ここでは試作用の簡易版と割り切っています。

    db.sql の直後にバッククォートで SQL を書き、値を ${} で差し込む書き方(タグ付きテンプレート)がポイントです。${} に入れた値は SQL に直接埋め込まれるのではなく、プレースホルダー(?)として安全に渡されます。SQLインジェクション(=入力値を悪用してSQLを書き換える攻撃)を防ぐ基本なので、文字列を連結して SQL を組み立てることはしません。

    手順4:入力チェックを zod で書く

    server/utils/pointSchema.ts

    import { z } from 'zod'
    import { CATEGORIES } from '#shared/types/point'
    
    // POST / PUT /api/points の入力検証。ブラウザ側の入力チェックとは別に、サーバーで必ず検証する。
    export const pointInputSchema = z.object({
      name: z.string().trim().min(1, '名称は必須です').max(100),
      category: z.enum(CATEGORIES),
      lat: z.number().min(-90).max(90),
      lng: z.number().min(-180).max(180),
      memo: z.string().max(2000).default(''),
    })
    
    export type PointInput = z.input<typeof pointInputSchema>
    
    // ルートパラメータ /api/points/:id の検証
    export const pointIdSchema = z.object({
      id: z.coerce.number().int().positive(),
    })

    カテゴリは第2回で定義した CATEGORIES をそのまま使うので、カテゴリを増やしたときにチェックの修正漏れが起きません。server/utils/ に置いたものは Nuxt が自動で読み込むため、各APIファイルで import せずに使えます。

    📰 出典:Zod(公式ドキュメント)

    手順5:APIを書く

    server/api/points/index.get.ts

    import type { PointRow } from '../../utils/points'
    
    // GET /api/points … 全地点を返す(第9回で表示範囲による絞り込みを追加)
    export default defineEventHandler(async () => {
      const db = useDatabase()
      const { rows } = await db.sql`SELECT * FROM points ORDER BY id`
      return (rows as unknown as PointRow[]).map(toPoint)
    })

    toPoint は、DBの列名(updated_at のような snake_case)を画面で使う形(updatedAt)に変換する小さな関数です(server/utils/points.ts)。

    server/api/points/index.post.ts

    import type { PointRow } from '../../utils/points'
    
    // POST /api/points … 地点を1件登録
    export default defineEventHandler(async (event) => {
      // 検証に失敗すると 400(Validation Error)を返す
      const input = await readValidatedBody(event, (body) => pointInputSchema.parse(body))
      const db = useDatabase()
      const now = new Date().toISOString()
    
      const { rows } = await db.sql`INSERT INTO points (name, category, lat, lng, memo, updated_at)
        VALUES (${input.name}, ${input.category}, ${input.lat}, ${input.lng}, ${input.memo}, ${now})
        RETURNING *`
    
      setResponseStatus(event, 201)
      return toPoint(rows![0] as unknown as PointRow)
    })

    readValidatedBody は、リクエストの本文を読み取って検証関数に通す関数です。zod の parse が失敗すると例外が投げられ、自動的に 400(Validation Error)が返ります。RETURNING * は、登録した行をそのまま返してもらう SQL の書き方で、SQLite と PostgreSQL の両方で使えます。

    server/api/points/[id].put.ts(要点)

    export default defineEventHandler(async (event) => {
      const { id } = await getValidatedRouterParams(event, (p) => pointIdSchema.parse(p))
      const input = await readValidatedBody(event, (body) => pointInputSchema.parse(body))
      const db = useDatabase()
      const now = new Date().toISOString()
    
      const { rows } = await db.sql`UPDATE points
        SET name = ${input.name}, category = ${input.category}, lat = ${input.lat},
            lng = ${input.lng}, memo = ${input.memo}, updated_at = ${now}
        WHERE id = ${id}
        RETURNING *`
    
      if (!rows?.length) {
        throw createError({ statusCode: 404, statusMessage: 'Point not found' })
      }
      return toPoint(rows[0] as unknown as PointRow)
    })

    削除([id].delete.ts)も同じ形で、DELETE FROM points WHERE id = ${id} RETURNING id の結果が空なら 404、削除できたら 204 を返します。

    手順6:画面はAPIから取得する

    app/pages/index.vue(script 部分)

    <script setup lang="ts">
    import type { Point } from '#shared/types/point'
    
    const { data: points } = await useFetch<Point[]>('/api/points', { default: () => [] })
    const { render } = useMarkers()
    const map = shallowRef<google.maps.Map | null>(null)
    
    function onMapReady(m: google.maps.Map) {
      map.value = m
    }
    
    // 地図の準備ができた時点と、データが変わった時点でピンを描き直す
    watch([map, points], ([m, list]) => {
      if (m) render(m, list)
    })
    </script>

    useFetch は、サーバー側でHTMLを作るときにデータを取得し、その結果をブラウザに引き継ぎます。「地図の準備」と「データの取得」はどちらが先に終わるか分からないため、両方を watch して、そろった時点でピンを描くようにしました。凡例の横には件数(「全 20 件」)も出しています。

    動作確認の方法

    1. npm run dev で起動し、http://localhost:3000 で第2回と同じ20個のピンが出ることを確認
    2. curl localhost:3000/api/points で20件のJSONが返ることを確認
    3. 次のコマンドで1件追加し、画面を再読み込みするとピンが21個になることを確認
    curl -X POST -H 'Content-Type: application/json' \
      -d '{"name":"テスト店","category":"store","lat":35.68,"lng":139.76}' \
      localhost:3000/api/points
    1. "lat":999 や "category":"foo" で送ると 400 が返ることを確認
    2. git status に .data/gmap.sqlite が出てこない(gitignore されている)ことを確認

    筆者の環境では、ビルド後のサーバー(node .output/server/index.mjs)と npm run dev の両方で、一覧取得(20件)、登録(201)、不正な緯度・カテゴリ(400)、更新(200)、存在しないIDの更新・削除(404)、削除(204)、数字でないID(400)、サーバーが返すHTMLの「全 20 件」表示、.data/gmap.sqlite が git の管理対象外になることを curl で確認しました。ピンの地図上での表示は、執筆環境に本物のAPIキーがないため確認できていません。 ご自身のキーを .env に設定して確認してください。

    つまずきやすい点・本番で必要になること

    • experimental 機能であること:Nitro のデータベース機能は、将来のバージョンで設定方法が変わる可能性があります。アップデート時はリリースノートを確認してください
    • SQLite ファイルの置き場所:.data/ は起動したディレクトリの下にできます。サーバーを複数台に増やす構成や、再デプロイでファイルが消える環境(コンテナ等)では SQLite のままでは運用できません。本番は PostgreSQL などへの切り替えを前提にします
    • 認証がない:この段階では、URLを知っていれば誰でも登録・削除できます。社内公開の前に、ログイン(社内の認証基盤との連携など)と、誰が何をできるかの権限設計が必要です。連載ではサンプルを小さく保つため省略しています
    • 同時編集:2人が同じ地点を同時に更新すると、後から保存した内容で上書きされます。業務で問題になる場合は、更新日時を使った競合チェックを追加します

    発注者向けメモ

    • この回の作業は Google の課金とは無関係です。DB と API は自社で作る部分で、費用は開発工数とサーバー代です。ここを地図サービスから切り離しておくと、将来地図サービスを変える場合にもデータと業務ロジックを使い回せます
    • 「どこから来た座標か」を最初から記録する設計かを確認しましょう。Google の住所変換・施設検索で得た座標には保存期間の制限があり、後から区別しようとしても難しくなります
    • 認証・権限・操作ログは見積りの別項目になりやすい部分です。誰が見られて、誰が登録・削除できるのか、変更履歴が必要かを要件として伝えてください
    • 本番のDBとバックアップも確認事項です。試作で SQLite を使っていても、本番では何を使い、どう復旧するのかを決めておく必要があります

    開発会社への確認に使える質問例です。

    • 「地点データは自社のDBに保存しますか?Google 由来の座標とそれ以外は区別して記録されますか?」
    • 「入力値のチェックは、画面側だけでなくサーバー側でも行っていますか?」
    • 「本番のデータベースとバックアップ・復旧の方法は、どう想定していますか?」

    まとめと次回予告

    この回では、Nitro のデータベース機能と Node.js 組み込みの SQLite を使って、地点データの登録・取得・更新・削除APIを作り、画面を useFetch でAPIから読む形に切り替えました。型を shared/ で画面とサーバーに共有する、SQL は db.sql のテンプレート記法で値を安全に渡す、入力はサーバー側で zod で検証する、の3点が押さえどころです。

    次回は「マーカーをクリックしてInfoWindowと詳細パネルを出す」です。ピンをクリックすると吹き出し(InfoWindow)に概要、右側のパネルに詳細が出るようにし、キーボードでも操作できるようにします。

    この連載の記事一覧

    この記事は連載「NuxtとGoogle Mapsで作る情報集約マップ」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次