AppSync でリアルタイム投票アプリを作ってみた
前提
- フロントエンドは index.html のみ
- Amplify や JavaScript フレームワークは不使用
1. AppSync API の作成
以下の内容で作成しました。
- API タイプ: GraphQL APIs
- GraphQL API データソース: Design from scratch
- API 名: VotingApp
- DynamoDB テーブルを使用する GraphQL タイプを作成: 後で GraphQL リソースを作成
2. DynamoDB テーブルの作成
以下の内容で作成しました。
- テーブル名: VotingTable
- パーティションキー: id
- 文字列
- テーブル設定: デフォルト設定
3. AppSync API にデータソースを追加
手順 1 で作成した API のデータソースに手順 2 で作成した DynamoDB テーブルを追加します。
- データソース名: VotingTableDataSource
- データソースタイプ: AMAZON_DYNAMODB
- リージョン: AP‐NORTHEAST-1
- テーブル名: VotingTable
- 既存のロールを作成または使用する: 新しいロール
4. GraphQL スキーマの定義
AppSync コンソールのスキーマで以下の内容を定義して保存します。
type Vote {
id: ID!
question: String!
options: [String!]!
votes: [Int!]!
createdAt: String!
}
type Query {
getVote(id: ID!): Vote
listVotes: [Vote]
}
type Mutation {
createVote(question: String!, options: [String!]!): Vote
castVote(id: ID!, optionIndex: Int!): Vote
}
type Subscription {
onVoteUpdated(id: ID!): Vote
@aws_subscribe(mutations: ["castVote"])
}
5. リゾルバーの設定
リゾルバー > Mutation > createVote(...): Vote のアタッチをクリックします。

手順 3 で作成したデータソースを設定します。

リゾルバーコードに以下の内容を定義して保存します。
import { util } from '@aws-appsync/utils';
export function request(ctx) {
const id = util.autoId();
const now = util.time.nowISO8601();
const { question, options } = ctx.args;
// 投票数を0で初期化(選択肢の数だけ)
const votes = options.map(() => 0);
return {
operation: 'PutItem',
key: util.dynamodb.toMapValues({ id }),
attributeValues: util.dynamodb.toMapValues({
question,
options,
votes,
createdAt: now
})
};
}
export function response(ctx) {
return ctx.result;
}
同じ要領で castVote(...): Vote にもデータソースとリゾルバーコードを追加します。
import { util } from '@aws-appsync/utils';
export function request(ctx) {
const { id, optionIndex } = ctx.args;
return {
operation: 'UpdateItem',
key: util.dynamodb.toMapValues({ id }),
update: {
expression: 'ADD votes[' + optionIndex + '] :increment',
expressionValues: util.dynamodb.toMapValues({
':increment': 1
})
}
};
}
export function response(ctx) {
if (ctx.error) {
util.error(ctx.error.message, ctx.error.type);
}
return ctx.result;
}
同じ要領で Query > getVote(...): Vote にもデータソースとリゾルバーコードを追加します。
import { util } from '@aws-appsync/utils';
export function request(ctx) {
const { id } = ctx.args;
return {
operation: 'GetItem',
key: util.dynamodb.toMapValues({ id })
};
}
export function response(ctx) {
if (ctx.error) {
util.error(ctx.error.message, ctx.error.type);
}
return ctx.result;
}
6. GraphQL API のテスト
AppSync コンソール上のクエリでテストします。
まずは以下のクエリを実行します。
mutation CreateVote {
createVote(
question: "好きなプログラミング言語は?"
options: ["JavaScript", "Python", "Java", "Go"]
) {
id
question
options
votes
createdAt
}
}
実行後、以下の結果が表示されれば投票の対象となる項目の作成が完了します。
{
"data": {
"createVote": {
"id": "a741be93-59e2-49df-a147-e2ab2d4edc20",
"question": "好きなプログラミング言語は?",
"options": [
"JavaScript",
"Python",
"Java",
"Go"
],
"votes": [
0,
0,
0,
0
],
"createdAt": "2025-09-05T00:31:50.719Z"
}
}
}
この時点で DynamoDB にもデータが作成されています。

次に投票機能をテストするために以下のクエリを実行します。
id には上述の createVote で取得した id を指定します。
mutation CastVote {
castVote(
id: "a741be93-59e2-49df-a147-e2ab2d4edc20"
optionIndex: 0
) {
id
question
options
votes
}
}
実行後、以下の結果が表示されれば投票は成功です。
{
"data": {
"castVote": {
"id": "a741be93-59e2-49df-a147-e2ab2d4edc20",
"question": "好きなプログラミング言語は?",
"options": [
"JavaScript",
"Python",
"Java",
"Go"
],
"votes": [
1,
0,
0,
0
]
}
}
}
この時点で DynamoDB にもデータも更新されています。

次に Subscription の機能をテストしますが、動作確認のためにクエリページのブラウザのタブを複製しておきます。
片方のタブでは以下のクエリを定義しますが、この時点では実行しません。
mutation CastVote {
castVote(
id: "a741be93-59e2-49df-a147-e2ab2d4edc20"
optionIndex: 1
) {
id
question
options
votes
}
}
もう 1 つのタブで Subscription 用に以下のクエリを定義します。
subscription OnVoteUpdated {
onVoteUpdated(id: "a741be93-59e2-49df-a147-e2ab2d4edc20") {
id
question
options
votes
}
}
各クエリの定義後、以下の順番で実行します。
- Subscription のクエリ実行
- CastVote のクエリ実行
2 のクエリが成功したタイミングで 1 のクエリの結果にも以下の内容が表示されれば OK です。
{
"data": {
"onVoteUpdated": {
"id": "a741be93-59e2-49df-a147-e2ab2d4edc20",
"question": "好きなプログラミング言語は?",
"options": [
"JavaScript",
"Python",
"Java",
"Go"
],
"votes": [
1,
1,
0,
0
]
}
}
}
7. フロントエンドアプリの作成
テキストエディタなどで index.html を作成し、以下のコードを貼り付けます。
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>リアルタイム投票システム</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.vote-option {
margin: 15px 0;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
background: #f9f9f9;
}
.vote-button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
.vote-button:hover {
background: #0056b3;
}
.vote-bar {
height: 20px;
background: #007bff;
margin-top: 10px;
border-radius: 3px;
transition: width 0.3s ease;
}
.vote-count {
font-weight: bold;
margin-left: 10px;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
.status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.input-section {
margin-bottom: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 5px;
}
input[type="text"] {
width: 300px;
padding: 8px;
margin: 5px;
border: 1px solid #ddd;
border-radius: 3px;
}
button {
background: #28a745;
color: white;
border: none;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
button:hover {
opacity: 0.8;
}
</style>
</head>
<body>
<div class="container">
<h1>🗳️ リアルタイム投票システム</h1>
<!-- 設定セクション -->
<div class="input-section">
<h3>AppSync設定</h3>
<div>
<input type="text" id="apiEndpoint" placeholder="AppSync API エンドポイント" />
</div>
<div>
<input type="text" id="apiKey" placeholder="API キー" />
</div>
<button onclick="saveConfig()">設定保存</button>
<div id="configStatus"></div>
</div>
<!-- 投票作成セクション -->
<div class="input-section">
<h3>新しい投票を作成</h3>
<div>
<input type="text" id="questionInput" placeholder="質問を入力" />
</div>
<div>
<input type="text" id="option1" placeholder="選択肢1" />
<input type="text" id="option2" placeholder="選択肢2" />
</div>
<div>
<input type="text" id="option3" placeholder="選択肢3" />
<input type="text" id="option4" placeholder="選択肢4" />
</div>
<button onclick="createVote()">投票作成</button>
</div>
<!-- 既存投票読み込みセクション -->
<div class="input-section">
<h3>既存の投票を読み込み</h3>
<input type="text" id="voteIdInput" placeholder="投票ID" />
<button onclick="loadVote()">読み込み</button>
</div>
<!-- ステータス表示 -->
<div id="status"></div>
<!-- 投票表示エリア -->
<div id="voteArea" style="display: none;">
<h2 id="voteQuestion"></h2>
<div id="voteOptions"></div>
<div id="connectionStatus" class="status info">
🔄 リアルタイム接続: 準備中...
</div>
</div>
</div>
<script>
// グローバル変数
let config = {
endpoint: '',
apiKey: ''
};
let currentVote = null;
let websocket = null;
// 設定保存
function saveConfig() {
const endpoint = document.getElementById('apiEndpoint').value.trim();
const apiKey = document.getElementById('apiKey').value.trim();
if (!endpoint || !apiKey) {
showStatus('エンドポイントとAPIキーを入力してください', 'error');
return;
}
config.endpoint = endpoint;
config.apiKey = apiKey;
// ローカルストレージに保存
localStorage.setItem('appSyncConfig', JSON.stringify(config));
showStatus('設定が保存されました', 'success');
}
// 設定読み込み
function loadConfig() {
const saved = localStorage.getItem('appSyncConfig');
if (saved) {
config = JSON.parse(saved);
document.getElementById('apiEndpoint').value = config.endpoint;
document.getElementById('apiKey').value = config.apiKey;
}
}
// GraphQL リクエスト送信
async function sendGraphQLRequest(query, variables = {}) {
if (!config.endpoint || !config.apiKey) {
throw new Error('AppSync設定が必要です');
}
const response = await fetch(config.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': config.apiKey
},
body: JSON.stringify({
query: query,
variables: variables
})
});
const result = await response.json();
if (result.errors) {
throw new Error(result.errors[0].message);
}
return result.data;
}
// 投票作成
async function createVote() {
try {
const question = document.getElementById('questionInput').value.trim();
const options = [
document.getElementById('option1').value.trim(),
document.getElementById('option2').value.trim(),
document.getElementById('option3').value.trim(),
document.getElementById('option4').value.trim()
].filter(option => option !== ''); // 空の選択肢を除外
if (!question || options.length < 2) {
showStatus('質問と最低2つの選択肢を入力してください', 'error');
return;
}
showStatus('投票を作成中...', 'info');
const query = `
mutation CreateVote($question: String!, $options: [String!]!) {
createVote(question: $question, options: $options) {
id
question
options
votes
createdAt
}
}
`;
const data = await sendGraphQLRequest(query, { question, options });
currentVote = data.createVote;
showStatus(`投票が作成されました!ID: ${currentVote.id}`, 'success');
displayVote();
startRealtimeConnection();
// 入力フィールドをクリア
document.getElementById('questionInput').value = '';
document.getElementById('option1').value = '';
document.getElementById('option2').value = '';
document.getElementById('option3').value = '';
document.getElementById('option4').value = '';
} catch (error) {
showStatus(`エラー: ${error.message}`, 'error');
}
}
// 投票読み込み
async function loadVote() {
try {
const voteId = document.getElementById('voteIdInput').value.trim();
if (!voteId) {
showStatus('投票IDを入力してください', 'error');
return;
}
showStatus('投票を読み込み中...', 'info');
const query = `
query GetVote($id: ID!) {
getVote(id: $id) {
id
question
options
votes
createdAt
}
}
`;
const data = await sendGraphQLRequest(query, { id: voteId });
if (!data.getVote) {
showStatus('投票が見つかりませんでした', 'error');
return;
}
currentVote = data.getVote;
showStatus('投票を読み込みました', 'success');
displayVote();
startRealtimeConnection();
} catch (error) {
showStatus(`エラー: ${error.message}`, 'error');
}
}
// 投票実行
async function castVote(optionIndex) {
try {
showStatus('投票中...', 'info');
const query = `
mutation CastVote($id: ID!, $optionIndex: Int!) {
castVote(id: $id, optionIndex: $optionIndex) {
id
question
options
votes
}
}
`;
const data = await sendGraphQLRequest(query, {
id: currentVote.id,
optionIndex: optionIndex
});
showStatus('投票しました!', 'success');
// リアルタイム更新で自動的に画面が更新されます
} catch (error) {
showStatus(`投票エラー: ${error.message}`, 'error');
}
}
// 投票表示
function displayVote() {
if (!currentVote) return;
document.getElementById('voteQuestion').textContent = currentVote.question;
// 投票IDを表示するための要素を追加
const voteArea = document.getElementById('voteArea');
let idDisplay = document.getElementById('voteIdDisplay');
if (!idDisplay) {
idDisplay = document.createElement('div');
idDisplay.id = 'voteIdDisplay';
idDisplay.style.cssText = 'background: #e9ecef; padding: 10px; margin: 10px 0; border-radius: 5px; font-family: monospace;';
voteArea.insertBefore(idDisplay, document.getElementById('voteOptions'));
}
idDisplay.innerHTML = ``;
const optionsContainer = document.getElementById('voteOptions');
optionsContainer.innerHTML = '';
const totalVotes = currentVote.votes.reduce((sum, count) => sum + count, 0);
const maxVotes = Math.max(...currentVote.votes);
currentVote.options.forEach((option, index) => {
const voteCount = currentVote.votes[index];
const percentage = totalVotes > 0 ? (voteCount / totalVotes * 100).toFixed(1) : 0;
const barWidth = maxVotes > 0 ? (voteCount / maxVotes * 100) : 0;
const optionDiv = document.createElement('div');
optionDiv.className = 'vote-option';
optionDiv.innerHTML = ``;
optionsContainer.appendChild(optionDiv);
});
document.getElementById('voteArea').style.display = 'block';
}
// リアルタイム接続開始
function startRealtimeConnection() {
if (!currentVote) return;
document.getElementById('connectionStatus').innerHTML = '🟡 リアルタイム接続: ポーリング方式で監視中';
// 3秒ごとに投票データを更新
const pollInterval = setInterval(async () => {
try {
const query = `
query GetVote($id: ID!) {
getVote(id: $id) {
id
question
options
votes
createdAt
}
}
`;
const data = await sendGraphQLRequest(query, { id: currentVote.id });
if (data.getVote) {
// 投票数が変更されたかチェック
const oldVotes = JSON.stringify(currentVote.votes);
const newVotes = JSON.stringify(data.getVote.votes);
if (oldVotes !== newVotes) {
currentVote = data.getVote;
displayVote();
showStatus('🔄 投票が更新されました', 'info');
document.getElementById('connectionStatus').innerHTML = '🟢 リアルタイム接続: 最新データ取得';
} else {
document.getElementById('connectionStatus').innerHTML = '🟡 リアルタイム接続: 監視中';
}
}
} catch (error) {
console.error('ポーリングエラー:', error);
document.getElementById('connectionStatus').innerHTML = '🔴 リアルタイム接続: エラー';
}
}, 3000); // 3秒間隔
// ページを離れる時にポーリングを停止
window.pollInterval = pollInterval;
}
// ステータス表示
function showStatus(message, type = 'info') {
const statusDiv = document.getElementById('status');
statusDiv.innerHTML = ``;
// 3秒後に自動で消去(エラーメッセージ以外)
if (type !== 'error') {
setTimeout(() => {
statusDiv.innerHTML = '';
}, 3000);
}
}
// ページ読み込み時の初期化
window.onload = function() {
loadConfig();
showStatus('AppSyncの設定を入力して開始してください', 'info');
};
// ページを離れる時にWebSocket接続を閉じる
window.onbeforeunload = function() {
if (window.pollInterval) {
clearInterval(window.pollInterval);
}
};
</script>
</body>
</html>
index.html を zip ファイル化します。
8. Amplify アプリの作成
Amplify コンソールから「Git なしでデプロイ」を選択し、手順 7 で作成した zip ファイルをアップロードします。


9. 動作確認
Amplify アプリのデプロイ完了後、ドメインにアクセスします。

以下のページが表示されるので、まずは AppSync API のエンドポイントと API キーを設定します。
AppSync API のエンドポイントと API キーは AppSync コンソールから確認可能です。


設定の保存後、質問と選択肢を入力して投票を作成します。


投票作成後、票を入れて値が変わることを確認します。

DynamoDB にも投票のデータが保存されていることが確認できます。

これでリアルタイム投票アプリの完成です。
まとめ
今回は AppSync でリアルタイム投票アプリを作ってみました。
どなたかの参考になれば幸いです。
Discussion