This workflow corresponds to n8n.io template #17223 — we link there as the canonical source.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "",
"name": "Manage a grocery list from a self-hosted web app",
"tags": [],
"nodes": [
{
"id": "91d2dc37-3442-4ece-9c8b-abf8803e0996",
"name": "View List (GET)",
"type": "n8n-nodes-base.webhook",
"position": [
-480,
1888
],
"parameters": {
"path": "groceries",
"options": {},
"responseMode": "responseNode"
},
"typeVersion": 2.1
},
{
"id": "b33eddaf-7cdd-49d3-bd39-4312d6c7c4fe",
"name": "Postgres: Fetch Items",
"type": "n8n-nodes-base.postgres",
"onError": "continueErrorOutput",
"disabled": true,
"maxTries": 3,
"position": [
-80,
1888
],
"parameters": {
"query": "SELECT id, item, quantity, category, store, checked, added_by, created_at, checked_at FROM grocery_items WHERE ($1 = '' OR store ILIKE '%' || $1 || '%') ORDER BY checked ASC, CASE WHEN checked THEN checked_at ELSE created_at END DESC",
"options": {
"queryReplacement": "={{ [$json.query?.store ?? ''] }}"
},
"operation": "executeQuery"
},
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"typeVersion": 2.6,
"alwaysOutputData": true,
"waitBetweenTries": 5000
},
{
"id": "a440ba7d-ff7b-4aad-b64f-c75d3c03c5e6",
"name": "Postgres: Render Grocery Page",
"type": "n8n-nodes-base.code",
"disabled": true,
"position": [
144,
1872
],
"parameters": {
"jsCode": "const filterValue = ($('View List (GET)').first().json.query && $('View List (GET)').first().json.query.store) || '';\n\nconst rows = $input.all().map(function(i) { return i.json; }).filter(function(r) { return r && r.id; });\nconst unchecked = rows.filter(function(r) { return !r.checked; });\nconst checked = rows.filter(function(r) { return r.checked; });\n\nfunction esc(s) {\n if (s === null || s === undefined) return '';\n return String(s)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\nfunction itemRow(r) {\n var label = esc(r.item) + (r.quantity ? ' (' + esc(r.quantity) + ')' : '') + (r.category ? ' \u2014 ' + esc(r.category) : '') + (r.store ? ' @ ' + esc(r.store) : '');\n return '<form method=\"POST\" action=\"/webhook/groceries/check\" class=\"row\">' +\n '<input type=\"hidden\" name=\"id\" value=\"' + r.id + '\">' +\n '<input type=\"hidden\" name=\"current_filter\" value=\"' + esc(filterValue) + '\">' +\n '<button type=\"submit\" class=\"check-btn\">\u2610 ' + label + '</button>' +\n '</form>';\n}\n\nfunction checkedRow(r) {\n return '<div class=\"row checked-row\">\u2611 <span class=\"strike\">' + esc(r.item) + '</span></div>';\n}\n\nvar uncheckedHtml = unchecked.length\n ? unchecked.map(itemRow).join('\\n')\n : '<p class=\"empty\">Nothing to buy \u2014 add something below.</p>';\n\nvar checkedHtml = '<details class=\"checked-details\"><summary>Checked off (' + checked.length + ')</summary>' +\n (checked.length ? checked.map(checkedRow).join('\\n') : '<p class=\"empty\">Nothing checked off yet.</p>') +\n '</details>';\n\nvar filterForm = '<form method=\"GET\" action=\"/webhook/groceries\" class=\"filter-form\">' +\n '<input type=\"text\" name=\"store\" value=\"' + esc(filterValue) + '\" placeholder=\"Filter by store\">' +\n '<button type=\"submit\">Filter</button>' +\n (filterValue ? '<a class=\"clear-link\" href=\"/webhook/groceries\">Clear</a>' : '') +\n '</form>';\n\nvar html = '<!DOCTYPE html><html lang=\"en\"><head>' +\n '<meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">' +\n '<title>Grocery List</title>' +\n '<style>' +\n ':root{color-scheme:light dark;} body{font-family:-apple-system,system-ui,sans-serif;max-width:480px;margin:0 auto;padding:16px;}' +\n 'h1{font-size:1.4rem;}' +\n '.filter-form{display:flex;gap:8px;margin-bottom:16px;align-items:center;}' +\n '.filter-form input{flex:1;padding:8px;font-size:0.95rem;border-radius:8px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;}' +\n '.filter-form button{padding:8px 12px;border-radius:8px;border:none;background:#2563eb;color:white;}' +\n '.clear-link{font-size:0.85rem;opacity:.7;text-decoration:none;color:inherit;white-space:nowrap;}' +\n 'form.row{margin:8px 0;}' +\n '.check-btn{width:100%;text-align:left;font-size:1.05rem;padding:12px;border-radius:10px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;}' +\n '.checked-row{padding:8px 12px;opacity:.5;} .strike{text-decoration:line-through;} .empty{opacity:.6;}' +\n '.checked-details summary{cursor:pointer;font-size:1rem;opacity:.7;margin-top:32px;padding:8px 0;}' +\n '.add-form{margin-top:24px;padding-top:16px;border-top:1px solid rgba(128,128,128,.3);display:flex;flex-direction:column;gap:8px;}' +\n '.add-form input{padding:10px;font-size:1rem;border-radius:8px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;}' +\n '.add-form button{padding:12px;font-size:1rem;border-radius:8px;border:none;background:#2563eb;color:white;}' +\n '</style></head><body>' +\n '<h1>Grocery List</h1>' +\n filterForm +\n uncheckedHtml +\n checkedHtml +\n '<form class=\"add-form\" method=\"POST\" action=\"/webhook/groceries/add\">' +\n '<input type=\"hidden\" name=\"current_filter\" value=\"' + esc(filterValue) + '\">' +\n '<input type=\"text\" name=\"item\" placeholder=\"Item (e.g. Milk)\" required>' +\n '<input type=\"text\" name=\"quantity\" placeholder=\"Quantity (optional)\">' +\n '<input type=\"text\" name=\"category\" placeholder=\"Category (optional)\">' +\n '<input type=\"text\" name=\"store\" placeholder=\"Store (optional)\">' +\n '<button type=\"submit\">Add item</button>' +\n '</form>' +\n '</body></html>';\n\nreturn [{ json: { html: html } }];"
},
"typeVersion": 2
},
{
"id": "3d4fead5-edbf-40fa-a601-1765b7b0fbf0",
"name": "Postgres: Return HTML Page",
"type": "n8n-nodes-base.respondToWebhook",
"disabled": true,
"position": [
368,
1872
],
"parameters": {
"options": {
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "text/html; charset=UTF-8"
}
]
}
},
"respondWith": "text",
"responseBody": "={{ $json.html }}"
},
"typeVersion": 1.5
},
{
"id": "3288cc98-e682-4bde-87cc-923cb700c93a",
"name": "Respond With Error",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
832,
1632
],
"parameters": {
"options": {
"responseCode": 500
},
"respondWith": "json",
"responseBody": {
"error": "internal_error",
"message": "={{ $json.error?.message ?? 'Something went wrong. Please try again.' }}"
}
},
"typeVersion": 1.5
},
{
"id": "77afde3a-ab6e-4d76-9259-1b011190890d",
"name": "Sheets: Get Items",
"type": "n8n-nodes-base.googleSheets",
"disabled": true,
"maxTries": 3,
"position": [
-96,
2672
],
"parameters": {
"sheetName": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultName": "Sheet1"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultName": "Your Grocery Sheet"
}
},
"retryOnFail": true,
"typeVersion": 4.7,
"alwaysOutputData": true,
"waitBetweenTries": 5000
},
{
"id": "317d71bb-f35e-4b8b-b8a3-7ad96525b3e6",
"name": "Sheets: Render Grocery Page",
"type": "n8n-nodes-base.code",
"disabled": true,
"position": [
128,
2672
],
"parameters": {
"jsCode": "const filterValue = ($('View List (GET)').first().json.query && $('View List (GET)').first().json.query.store) || '';\n\nfunction isChecked(v) { return v === true || String(v).toUpperCase() === 'TRUE'; }\n\nconst allRows = $input.all().map(function(i) { return i.json; }).filter(function(r) { return r && r.row_number && r.item; });\nconst rows = filterValue\n ? allRows.filter(function(r) { return String(r.store || '').toLowerCase().indexOf(filterValue.toLowerCase()) !== -1; })\n : allRows;\n\nconst unchecked = rows.filter(function(r) { return !isChecked(r.checked); });\nconst checked = rows.filter(function(r) { return isChecked(r.checked); });\n\nfunction esc(s) {\n if (s === null || s === undefined) return '';\n return String(s)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\nfunction itemRow(r) {\n var label = esc(r.item) + (r.quantity ? ' (' + esc(r.quantity) + ')' : '') + (r.category ? ' \u2014 ' + esc(r.category) : '') + (r.store ? ' @ ' + esc(r.store) : '');\n return '<form method=\"POST\" action=\"/webhook/groceries/check\" class=\"row\">' +\n '<input type=\"hidden\" name=\"id\" value=\"' + r.row_number + '\">' +\n '<input type=\"hidden\" name=\"current_filter\" value=\"' + esc(filterValue) + '\">' +\n '<button type=\"submit\" class=\"check-btn\">\u2610 ' + label + '</button>' +\n '</form>';\n}\n\nfunction checkedRow(r) {\n return '<div class=\"row checked-row\">\u2611 <span class=\"strike\">' + esc(r.item) + '</span></div>';\n}\n\nvar uncheckedHtml = unchecked.length\n ? unchecked.map(itemRow).join('\\n')\n : '<p class=\"empty\">Nothing to buy \u2014 add something below.</p>';\n\nvar checkedHtml = '<details class=\"checked-details\"><summary>Checked off (' + checked.length + ')</summary>' +\n (checked.length ? checked.map(checkedRow).join('\\n') : '<p class=\"empty\">Nothing checked off yet.</p>') +\n '</details>';\n\nvar filterForm = '<form method=\"GET\" action=\"/webhook/groceries\" class=\"filter-form\">' +\n '<input type=\"text\" name=\"store\" value=\"' + esc(filterValue) + '\" placeholder=\"Filter by store\">' +\n '<button type=\"submit\">Filter</button>' +\n (filterValue ? '<a class=\"clear-link\" href=\"/webhook/groceries\">Clear</a>' : '') +\n '</form>';\n\nvar html = '<!DOCTYPE html><html lang=\"en\"><head>' +\n '<meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">' +\n '<title>Grocery List</title>' +\n '<style>' +\n ':root{color-scheme:light dark;} body{font-family:-apple-system,system-ui,sans-serif;max-width:480px;margin:0 auto;padding:16px;}' +\n 'h1{font-size:1.4rem;}' +\n '.filter-form{display:flex;gap:8px;margin-bottom:16px;align-items:center;}' +\n '.filter-form input{flex:1;padding:8px;font-size:0.95rem;border-radius:8px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;}' +\n '.filter-form button{padding:8px 12px;border-radius:8px;border:none;background:#2563eb;color:white;}' +\n '.clear-link{font-size:0.85rem;opacity:.7;text-decoration:none;color:inherit;white-space:nowrap;}' +\n 'form.row{margin:8px 0;}' +\n '.check-btn{width:100%;text-align:left;font-size:1.05rem;padding:12px;border-radius:10px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;}' +\n '.checked-row{padding:8px 12px;opacity:.5;} .strike{text-decoration:line-through;} .empty{opacity:.6;}' +\n '.checked-details summary{cursor:pointer;font-size:1rem;opacity:.7;margin-top:32px;padding:8px 0;}' +\n '.add-form{margin-top:24px;padding-top:16px;border-top:1px solid rgba(128,128,128,.3);display:flex;flex-direction:column;gap:8px;}' +\n '.add-form input{padding:10px;font-size:1rem;border-radius:8px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;}' +\n '.add-form button{padding:12px;font-size:1rem;border-radius:8px;border:none;background:#2563eb;color:white;}' +\n '</style></head><body>' +\n '<h1>Grocery List</h1>' +\n filterForm +\n uncheckedHtml +\n checkedHtml +\n '<form class=\"add-form\" method=\"POST\" action=\"/webhook/groceries/add\">' +\n '<input type=\"hidden\" name=\"current_filter\" value=\"' + esc(filterValue) + '\">' +\n '<input type=\"text\" name=\"item\" placeholder=\"Item (e.g. Milk)\" required>' +\n '<input type=\"text\" name=\"quantity\" placeholder=\"Quantity (optional)\">' +\n '<input type=\"text\" name=\"category\" placeholder=\"Category (optional)\">' +\n '<input type=\"text\" name=\"store\" placeholder=\"Store (optional)\">' +\n '<button type=\"submit\">Add item</button>' +\n '</form>' +\n '</body></html>';\n\nreturn [{ json: { html: html } }];"
},
"typeVersion": 2
},
{
"id": "b87a12a1-9468-461b-81fa-1452160b68e8",
"name": "Sheets: Return HTML Page",
"type": "n8n-nodes-base.respondToWebhook",
"disabled": true,
"position": [
352,
2672
],
"parameters": {
"options": {
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "text/html; charset=UTF-8"
}
]
}
},
"respondWith": "text",
"responseBody": "={{ $json.html }}"
},
"typeVersion": 1.5
},
{
"id": "2914b5b9-b8b0-4306-93cb-c291f9792532",
"name": "Add Item (POST)",
"type": "n8n-nodes-base.webhook",
"position": [
-480,
2096
],
"parameters": {
"path": "groceries/add",
"options": {},
"httpMethod": "POST",
"responseMode": "responseNode"
},
"typeVersion": 2.1
},
{
"id": "e4a810fc-9595-43c0-962d-26f43d43210c",
"name": "Postgres: Insert Item",
"type": "n8n-nodes-base.postgres",
"onError": "continueErrorOutput",
"disabled": true,
"maxTries": 3,
"position": [
-80,
2096
],
"parameters": {
"query": "INSERT INTO grocery_items (item, quantity, category, store, added_by) VALUES ($1, $2, $3, $4, $5) RETURNING id, item, quantity, category, store, checked, added_by, created_at",
"options": {
"queryReplacement": "={{ [$json.body?.item ?? '', $json.body?.quantity || null, $json.body?.category || null, $json.body?.store || null, 'me'] }}"
},
"operation": "executeQuery"
},
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"typeVersion": 2.6,
"waitBetweenTries": 5000
},
{
"id": "84f56c65-b5c8-4668-b56e-64cff0dc3817",
"name": "Postgres: Redirect After Add",
"type": "n8n-nodes-base.respondToWebhook",
"disabled": true,
"position": [
160,
2080
],
"parameters": {
"options": {
"responseCode": 303
},
"redirectURL": "={{ ($('Add Item (POST)').item.json.headers['x-forwarded-proto'] || 'http') + '://' + $('Add Item (POST)').item.json.headers.host + '/webhook/groceries' + ($('Add Item (POST)').item.json.body?.current_filter ? '?store=' + encodeURIComponent($('Add Item (POST)').item.json.body.current_filter) : '') }}",
"respondWith": "redirect"
},
"typeVersion": 1.5
},
{
"id": "62e149d8-e206-410f-b4da-3eeb29304fd8",
"name": "Sheets: Add Item",
"type": "n8n-nodes-base.googleSheets",
"onError": "continueErrorOutput",
"disabled": true,
"maxTries": 3,
"position": [
-96,
3088
],
"parameters": {
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultName": "Sheet1"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultName": "Your Grocery Sheet"
}
},
"retryOnFail": true,
"typeVersion": 4.7,
"waitBetweenTries": 5000
},
{
"id": "2ea06809-7b03-447f-888c-1bb26d2cdb78",
"name": "Sheets: Redirect After Add",
"type": "n8n-nodes-base.respondToWebhook",
"disabled": true,
"position": [
144,
3072
],
"parameters": {
"options": {
"responseCode": 303
},
"redirectURL": "={{ ($('Add Item (POST)').item.json.headers['x-forwarded-proto'] || 'http') + '://' + $('Add Item (POST)').item.json.headers.host + '/webhook/groceries' + ($('Add Item (POST)').item.json.body?.current_filter ? '?store=' + encodeURIComponent($('Add Item (POST)').item.json.body.current_filter) : '') }}",
"respondWith": "redirect"
},
"typeVersion": 1.5
},
{
"id": "b97aaa4f-8843-4ebd-84c2-9ac5d78e4e23",
"name": "Check Off (POST)",
"type": "n8n-nodes-base.webhook",
"position": [
-480,
2304
],
"parameters": {
"path": "groceries/check",
"options": {},
"httpMethod": "POST",
"responseMode": "responseNode"
},
"typeVersion": 2.1
},
{
"id": "fa3a5ac4-c9de-4713-a171-b2e1c9fc2544",
"name": "Postgres: Check Off Item",
"type": "n8n-nodes-base.postgres",
"onError": "continueErrorOutput",
"disabled": true,
"maxTries": 3,
"position": [
-80,
2304
],
"parameters": {
"query": "UPDATE grocery_items SET checked = TRUE, checked_at = now() WHERE ($1::int IS NOT NULL AND id = $1::int) RETURNING id, item, quantity, category, checked, added_by, created_at",
"options": {
"queryReplacement": "={{ [$json.body?.id ? Number($json.body.id) : null] }}"
},
"operation": "executeQuery"
},
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"typeVersion": 2.6,
"waitBetweenTries": 5000
},
{
"id": "f76a30a5-10a9-4d80-ae36-67f155f9d8da",
"name": "Postgres: Redirect After Check",
"type": "n8n-nodes-base.respondToWebhook",
"disabled": true,
"position": [
160,
2288
],
"parameters": {
"options": {
"responseCode": 303
},
"redirectURL": "={{ ($('Check Off (POST)').item.json.headers['x-forwarded-proto'] || 'http') + '://' + $('Check Off (POST)').item.json.headers.host + '/webhook/groceries' + ($('Check Off (POST)').item.json.body?.current_filter ? '?store=' + encodeURIComponent($('Check Off (POST)').item.json.body.current_filter) : '') }}",
"respondWith": "redirect"
},
"typeVersion": 1.5
},
{
"id": "87324330-f0c1-43d5-bbf5-55be17a07450",
"name": "Sheets: Check Off Item",
"type": "n8n-nodes-base.googleSheets",
"onError": "continueErrorOutput",
"disabled": true,
"maxTries": 3,
"position": [
-96,
2880
],
"parameters": {
"operation": "update",
"sheetName": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultName": "Sheet1"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultName": "Your Grocery Sheet"
}
},
"retryOnFail": true,
"typeVersion": 4.7,
"waitBetweenTries": 5000
},
{
"id": "e95fbca9-9993-4b0d-9df4-0a885e3d674f",
"name": "Sheets: Redirect After Check",
"type": "n8n-nodes-base.respondToWebhook",
"disabled": true,
"position": [
144,
2864
],
"parameters": {
"options": {
"responseCode": 303
},
"redirectURL": "={{ ($('Check Off (POST)').item.json.headers['x-forwarded-proto'] || 'http') + '://' + $('Check Off (POST)').item.json.headers.host + '/webhook/groceries' + ($('Check Off (POST)').item.json.body?.current_filter ? '?store=' + encodeURIComponent($('Check Off (POST)').item.json.body.current_filter) : '') }}",
"respondWith": "redirect"
},
"typeVersion": 1.5
},
{
"id": "c768ac21-7ba2-46e6-b0ec-165d8658a91f",
"name": "Sticky Note: Explainer (Overview, Setup & Requirements)",
"type": "n8n-nodes-base.stickyNote",
"position": [
-576,
-368
],
"parameters": {
"width": 1760,
"height": 524,
"content": "### Manage a grocery list from a self-hosted web app\nA mobile-friendly grocery list you host yourself in n8n. No login, no app to install, just a webpage you can bookmark on your phone. Pick one of three storage backends: n8n Data Tables (zero setup), Postgres, or Google Sheets.\n\n### How it works\nThree webhooks make up the whole app: one serves the list as an HTML page, one adds an item, one checks an item off. Adding or checking off an item redirects back to the list, so it behaves like a plain web form. No JavaScript framework required. Only the backend you enable runs; the other two stay disabled.\n\n### Setup\n1. Pick a backend in the sections below (Data Tables needs zero setup).\n2. Run the Setup trigger once to create storage automatically.\n3. Activate the workflow, then open the View List (GET) node and copy its Production URL.\n\n### Customization\nAdd fields to the form, restyle the inline CSS, or add Basic Auth in front of the webhooks. There's no login by default, so anyone with the link can view or edit the list."
},
"typeVersion": 1
},
{
"id": "9fbab2da-abd8-4aaf-8084-4c927adc196e",
"name": "Sticky Note: Postgres Backend Instructions",
"type": "n8n-nodes-base.stickyNote",
"position": [
-160,
1728
],
"parameters": {
"color": 7,
"width": 720,
"height": 772,
"content": "## Postgres backend (optional)\nBring your own database. Select your Postgres credential on these nodes, then run Setup once to create the table."
},
"typeVersion": 1
},
{
"id": "b0982c79-6d12-445b-936c-ba3f26aed959",
"name": "Sticky Note: Google Sheets Backend Instructions",
"type": "n8n-nodes-base.stickyNote",
"position": [
-160,
2528
],
"parameters": {
"color": 7,
"width": 720,
"height": 734,
"content": "## Google Sheets backend (optional)\nBring your own Google account. Select your credential on these nodes, then run Setup once. See the warning note below for known setup gaps."
},
"typeVersion": 1
},
{
"id": "56f48bf4-e241-49f7-b79c-4352a7005546",
"name": "Data Table: Fetch Items",
"type": "n8n-nodes-base.dataTable",
"position": [
-80,
1120
],
"parameters": {
"operation": "get",
"returnAll": true,
"dataTableId": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultName": "grocery_items (create this first)"
}
},
"typeVersion": 1.1,
"alwaysOutputData": true
},
{
"id": "00c7bf8b-0524-4f84-8c10-34112ba62f80",
"name": "Data Table: Render Grocery Page",
"type": "n8n-nodes-base.code",
"position": [
144,
1120
],
"parameters": {
"jsCode": "const filterValue = ($('View List (GET)').first().json.query && $('View List (GET)').first().json.query.store) || '';\n\nconst allRows = $input.all().map(function(i) { return i.json; }).filter(function(r) { return r && r.id; });\nconst rows = filterValue\n ? allRows.filter(function(r) { return String(r.store || '').toLowerCase().indexOf(filterValue.toLowerCase()) !== -1; })\n : allRows;\n\nconst unchecked = rows.filter(function(r) { return !r.checked; });\nconst checked = rows.filter(function(r) { return r.checked; });\n\nfunction esc(s) {\n if (s === null || s === undefined) return '';\n return String(s)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\nfunction itemRow(r) {\n var label = esc(r.item) + (r.quantity ? ' (' + esc(r.quantity) + ')' : '') + (r.category ? ' \u2014 ' + esc(r.category) : '') + (r.store ? ' @ ' + esc(r.store) : '');\n return '<form method=\"POST\" action=\"/webhook/groceries/check\" class=\"row\">' +\n '<input type=\"hidden\" name=\"id\" value=\"' + r.id + '\">' +\n '<input type=\"hidden\" name=\"current_filter\" value=\"' + esc(filterValue) + '\">' +\n '<button type=\"submit\" class=\"check-btn\">\u2610 ' + label + '</button>' +\n '</form>';\n}\n\nfunction checkedRow(r) {\n return '<div class=\"row checked-row\">\u2611 <span class=\"strike\">' + esc(r.item) + '</span></div>';\n}\n\nvar uncheckedHtml = unchecked.length\n ? unchecked.map(itemRow).join('\\n')\n : '<p class=\"empty\">Nothing to buy \u2014 add something below.</p>';\n\nvar checkedHtml = '<details class=\"checked-details\"><summary>Checked off (' + checked.length + ')</summary>' +\n (checked.length ? checked.map(checkedRow).join('\\n') : '<p class=\"empty\">Nothing checked off yet.</p>') +\n '</details>';\n\nvar filterForm = '<form method=\"GET\" action=\"/webhook/groceries\" class=\"filter-form\">' +\n '<input type=\"text\" name=\"store\" value=\"' + esc(filterValue) + '\" placeholder=\"Filter by store\">' +\n '<button type=\"submit\">Filter</button>' +\n (filterValue ? '<a class=\"clear-link\" href=\"/webhook/groceries\">Clear</a>' : '') +\n '</form>';\n\nvar html = '<!DOCTYPE html><html lang=\"en\"><head>' +\n '<meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">' +\n '<title>Grocery List</title>' +\n '<style>' +\n ':root{color-scheme:light dark;} body{font-family:-apple-system,system-ui,sans-serif;max-width:480px;margin:0 auto;padding:16px;}' +\n 'h1{font-size:1.4rem;}' +\n '.filter-form{display:flex;gap:8px;margin-bottom:16px;align-items:center;}' +\n '.filter-form input{flex:1;padding:8px;font-size:0.95rem;border-radius:8px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;}' +\n '.filter-form button{padding:8px 12px;border-radius:8px;border:none;background:#2563eb;color:white;}' +\n '.clear-link{font-size:0.85rem;opacity:.7;text-decoration:none;color:inherit;white-space:nowrap;}' +\n 'form.row{margin:8px 0;}' +\n '.check-btn{width:100%;text-align:left;font-size:1.05rem;padding:12px;border-radius:10px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;}' +\n '.checked-row{padding:8px 12px;opacity:.5;} .strike{text-decoration:line-through;} .empty{opacity:.6;}' +\n '.checked-details summary{cursor:pointer;font-size:1rem;opacity:.7;margin-top:32px;padding:8px 0;}' +\n '.add-form{margin-top:24px;padding-top:16px;border-top:1px solid rgba(128,128,128,.3);display:flex;flex-direction:column;gap:8px;}' +\n '.add-form input{padding:10px;font-size:1rem;border-radius:8px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;}' +\n '.add-form button{padding:12px;font-size:1rem;border-radius:8px;border:none;background:#2563eb;color:white;}' +\n '</style></head><body>' +\n '<h1>Grocery List</h1>' +\n filterForm +\n uncheckedHtml +\n checkedHtml +\n '<form class=\"add-form\" method=\"POST\" action=\"/webhook/groceries/add\">' +\n '<input type=\"hidden\" name=\"current_filter\" value=\"' + esc(filterValue) + '\">' +\n '<input type=\"text\" name=\"item\" placeholder=\"Item (e.g. Milk)\" required>' +\n '<input type=\"text\" name=\"quantity\" placeholder=\"Quantity (optional)\">' +\n '<input type=\"text\" name=\"category\" placeholder=\"Category (optional)\">' +\n '<input type=\"text\" name=\"store\" placeholder=\"Store (optional)\">' +\n '<button type=\"submit\">Add item</button>' +\n '</form>' +\n '</body></html>';\n\nreturn [{ json: { html: html } }];"
},
"typeVersion": 2
},
{
"id": "405fd586-d7fb-4e2f-964d-6f59bc70696d",
"name": "Data Table: Return HTML Page",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
368,
1120
],
"parameters": {
"options": {
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "text/html; charset=UTF-8"
}
]
}
},
"respondWith": "text",
"responseBody": "={{ $json.html }}"
},
"typeVersion": 1.5
},
{
"id": "e54cede6-3c18-4aa0-963f-0af68d4fc775",
"name": "Data Table: Insert Item",
"type": "n8n-nodes-base.dataTable",
"onError": "continueErrorOutput",
"position": [
-80,
1296
],
"parameters": {
"columns": {
"value": {
"item": "={{ $json.body?.item ?? '' }}",
"store": "={{ $json.body?.store ?? '' }}",
"checked": false,
"added_by": "me",
"category": "={{ $json.body?.category ?? '' }}",
"quantity": "={{ $json.body?.quantity ?? '' }}"
},
"schema": [
{
"id": "item",
"type": "string",
"display": true,
"required": false,
"displayName": "item",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "quantity",
"type": "string",
"display": true,
"required": false,
"displayName": "quantity",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "category",
"type": "string",
"display": true,
"required": false,
"displayName": "category",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "store",
"type": "string",
"display": true,
"required": false,
"displayName": "store",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "checked",
"type": "boolean",
"display": true,
"required": false,
"displayName": "checked",
"defaultMatch": false,
"canBeUsedToMatch": false
},
{
"id": "added_by",
"type": "string",
"display": true,
"required": false,
"displayName": "added_by",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow"
},
"options": {},
"dataTableId": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultName": "grocery_items (create this first)"
}
},
"typeVersion": 1.1
},
{
"id": "045f54fd-5a20-4d2b-85b1-8d0880826814",
"name": "Data Table: Redirect After Add",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
144,
1280
],
"parameters": {
"options": {
"responseCode": 303
},
"redirectURL": "={{ ($('Add Item (POST)').item.json.headers['x-forwarded-proto'] || 'http') + '://' + $('Add Item (POST)').item.json.headers.host + '/webhook/groceries' + ($('Add Item (POST)').item.json.body?.current_filter ? '?store=' + encodeURIComponent($('Add Item (POST)').item.json.body.current_filter) : '') }}",
"respondWith": "redirect"
},
"typeVersion": 1.5
},
{
"id": "46970842-8ebe-498c-b269-af8ffc1f50da",
"name": "Data Table: Check Off Item",
"type": "n8n-nodes-base.dataTable",
"onError": "continueErrorOutput",
"position": [
-80,
1488
],
"parameters": {
"columns": {
"value": {
"checked": true
},
"schema": [
{
"id": "checked",
"type": "boolean",
"display": true,
"required": false,
"displayName": "checked",
"defaultMatch": false,
"canBeUsedToMatch": false
}
],
"mappingMode": "defineBelow"
},
"filters": {
"conditions": [
{
"keyValue": "={{ $json.body?.id }}"
}
]
},
"options": {},
"matchType": "allConditions",
"operation": "update",
"dataTableId": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultName": "grocery_items (create this first)"
}
},
"typeVersion": 1.1
},
{
"id": "3983cfd4-1258-45ef-993b-462625971380",
"name": "Data Table: Redirect After Check",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
160,
1472
],
"parameters": {
"options": {
"responseCode": 303
},
"redirectURL": "={{ ($('Check Off (POST)').item.json.headers['x-forwarded-proto'] || 'http') + '://' + $('Check Off (POST)').item.json.headers.host + '/webhook/groceries' + ($('Check Off (POST)').item.json.body?.current_filter ? '?store=' + encodeURIComponent($('Check Off (POST)').item.json.body.current_filter) : '') }}",
"respondWith": "redirect"
},
"typeVersion": 1.5
},
{
"id": "4c597a25-7d32-4c41-899b-9aed45ea5dbd",
"name": "Sticky Note: Data Tables Backend Instructions",
"type": "n8n-nodes-base.stickyNote",
"position": [
-160,
960
],
"parameters": {
"color": 7,
"width": 720,
"height": 738,
"content": "## Data Tables backend (default)\nNo account needed, already enabled. Run Setup once to create the grocery_items table automatically."
},
"typeVersion": 1
},
{
"id": "fa8f7295-626b-4f96-b7fe-2c815cc1138d",
"name": "\u25b6 Click \"Execute Workflow\" to Set Up Storage",
"type": "n8n-nodes-base.manualTrigger",
"position": [
-528,
480
],
"parameters": {},
"typeVersion": 1
},
{
"id": "29846d9b-3557-4790-b9b3-b765c9d43988",
"name": "Setup: Create Data Table",
"type": "n8n-nodes-base.dataTable",
"position": [
-288,
320
],
"parameters": {
"columns": {
"column": [
{
"name": "item"
},
{
"name": "quantity"
},
{
"name": "category"
},
{
"name": "store"
},
{
"name": "checked",
"type": "boolean"
},
{
"name": "added_by"
}
]
},
"options": {
"createIfNotExists": true
},
"resource": "table",
"operation": "create",
"tableName": "grocery_items"
},
"typeVersion": 1.1
},
{
"id": "59f8d268-ca38-4c7b-a72e-44b90b45fc55",
"name": "Setup: Create Postgres Table",
"type": "n8n-nodes-base.postgres",
"disabled": true,
"position": [
-288,
480
],
"parameters": {
"query": "CREATE TABLE IF NOT EXISTS grocery_items (\n id SERIAL PRIMARY KEY,\n item TEXT NOT NULL,\n quantity TEXT,\n category TEXT,\n store TEXT,\n checked BOOLEAN NOT NULL DEFAULT FALSE,\n added_by TEXT NOT NULL DEFAULT 'me',\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n checked_at TIMESTAMPTZ\n);",
"options": {},
"operation": "executeQuery"
},
"typeVersion": 2.6
},
{
"id": "8a29c23f-8196-4731-99cf-7b2353796b54",
"name": "Setup: Create Google Sheet",
"type": "n8n-nodes-base.googleSheets",
"disabled": true,
"position": [
-288,
688
],
"parameters": {
"title": "Grocery List",
"options": {},
"resource": "spreadsheet",
"sheetsUi": {
"sheetValues": [
{
"title": "Sheet1"
}
]
}
},
"typeVersion": 4.7
},
{
"id": "9248e45a-4e19-4007-9f25-034ffe55edc7",
"name": "Setup: Write Sheet Header Row",
"type": "n8n-nodes-base.googleSheets",
"disabled": true,
"position": [
48,
688
],
"parameters": {
"columns": {
"value": {
"item": "",
"store": "",
"checked": "",
"added_by": "",
"category": "",
"quantity": ""
},
"schema": [
{
"id": "item",
"type": "string",
"display": true,
"required": false,
"displayName": "item",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "quantity",
"type": "string",
"display": true,
"required": false,
"displayName": "quantity",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "category",
"type": "string",
"display": true,
"required": false,
"displayName": "category",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "store",
"type": "string",
"display": true,
"required": false,
"displayName": "store",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "checked",
"type": "string",
"display": true,
"required": false,
"displayName": "checked",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "added_by",
"type": "string",
"display": true,
"required": false,
"displayName": "added_by",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow"
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "name",
"value": "Sheet1",
"cachedResultName": "Sheet1"
},
"documentId": {
"__rl": true,
"mode": "id",
"value": "={{ $json.spreadsheetId }}",
"cachedResultName": "Grocery List"
}
},
"typeVersion": 4.7
},
{
"id": "88c6cdaa-9717-45b9-bec9-f40703e35df3",
"name": "Sticky Note: One-Click Setup Trigger",
"type": "n8n-nodes-base.stickyNote",
"position": [
-576,
176
],
"parameters": {
"color": 7,
"width": 996,
"height": 732,
"content": "## One-time setup\nRun the trigger once. Only your enabled backend runs; the other two are skipped, no error. Safe to run again."
},
"typeVersion": 1
},
{
"id": "77135f1a-c3e9-4280-a09b-c5eaa5278142",
"name": "Sticky Note: Google Sheets Setup Warning",
"type": "n8n-nodes-base.stickyNote",
"position": [
-48,
480
],
"parameters": {
"color": 3,
"width": 328,
"height": 412,
"content": "### Google Sheets gaps\nCheck row 1 has clean headers after Setup: item, quantity, category, store, checked, added_by. If Sheets: Add Item or Check Off Item show empty field mappings, re-enter them manually."
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"availableInMCP": true,
"executionOrder": "v1"
},
"versionId": "",
"nodeGroups": [],
"connections": {
"Add Item (POST)": {
"main": [
[
{
"node": "Postgres: Insert Item",
"type": "main",
"index": 0
},
{
"node": "Sheets: Add Item",
"type": "main",
"index": 0
},
{
"node": "Data Table: Insert Item",
"type": "main",
"index": 0
}
]
]
},
"View List (GET)": {
"main": [
[
{
"node": "Postgres: Fetch Items",
"type": "main",
"index": 0
},
{
"node": "Sheets: Get Items",
"type": "main",
"index": 0
},
{
"node": "Data Table: Fetch Items",
"type": "main",
"index": 0
}
]
]
},
"Check Off (POST)": {
"main": [
[
{
"node": "Postgres: Check Off Item",
"type": "main",
"index": 0
},
{
"node": "Sheets: Check Off Item",
"type": "main",
"index": 0
},
{
"node": "Data Table: Check Off Item",
"type": "main",
"index": 0
}
]
]
},
"Sheets: Add Item": {
"main": [
[
{
"node": "Sheets: Redirect After Add",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond With Error",
"type": "main",
"index": 0
}
]
]
},
"Sheets: Get Items": {
"main": [
[
{
"node": "Sheets: Render Grocery Page",
"type": "main",
"index": 0
}
]
]
},
"Postgres: Fetch Items": {
"main": [
[
{
"node": "Postgres: Render Grocery Page",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond With Error",
"type": "main",
"index": 0
}
]
]
},
"Postgres: Insert Item": {
"main": [
[
{
"node": "Postgres: Redirect After Add",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond With Error",
"type": "main",
"index": 0
}
]
]
},
"Sheets: Check Off Item": {
"main": [
[
{
"node": "Sheets: Redirect After Check",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond With Error",
"type": "main",
"index": 0
}
]
]
},
"Data Table: Fetch Items": {
"main": [
[
{
"node": "Data Table: Render Grocery Page",
"type": "main",
"index": 0
}
]
]
},
"Data Table: Insert Item": {
"main": [
[
{
"node": "Data Table: Redirect After Add",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond With Error",
"type": "main",
"index": 0
}
]
]
},
"Postgres: Check Off Item": {
"main": [
[
{
"node": "Postgres: Redirect After Check",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond With Error",
"type": "main",
"index": 0
}
]
]
},
"Data Table: Check Off Item": {
"main": [
[
{
"node": "Data Table: Redirect After Check",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond With Error",
"type": "main",
"index": 0
}
]
]
},
"Setup: Create Google Sheet": {
"main": [
[
{
"node": "Setup: Write Sheet Header Row",
"type": "main",
"index": 0
}
]
]
},
"Sheets: Render Grocery Page": {
"main": [
[
{
"node": "Sheets: Return HTML Page",
"type": "main",
"index": 0
}
]
]
},
"Postgres: Render Grocery Page": {
"main": [
[
{
"node": "Postgres: Return HTML Page",
"type": "main",
"index": 0
}
]
]
},
"Data Table: Render Grocery Page": {
"main": [
[
{
"node": "Data Table: Return HTML Page",
"type": "main",
"index": 0
}
]
]
},
"\u25b6 Click \"Execute Workflow\" to Set Up Storage": {
"main": [
[
{
"node": "Setup: Create Data Table",
"type": "main",
"index": 0
},
{
"node": "Setup: Create Postgres Table",
"type": "main",
"index": 0
},
{
"node": "Setup: Create Google Sheet",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
postgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow hosts a simple, mobile-friendly grocery list web page in n8n and stores items in n8n Data Tables (default), PostgreSQL, or Google Sheets, letting you view the list, add items, and check items off via webhooks. Receives a GET request on the /groceries webhook,…
Source: https://n8n.io/workflows/17223/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Receive request via webhook with customer question Analyze sentiment and detect urgency using JavaScript Send urgent alerts to Slack for critical cases Search knowledge base and fetch conversation his
PURPOSE: Automatically send professional appointment reminders via email and SMS to reduce no-shows and improve patient experience.
This comprehensive n8n workflow automates the entire travel business call management process, from initial customer inquiries to trip bookings and marketing outreach. The system handles incoming calls
AUTO MESSAGE NEW CONNECTION. Uses stickyNote, googleSheets, postgres, httpRequest. Webhook trigger; 13 nodes.
WF6 — SecureVault Security Alert Notifications. Uses gmail, telegram, postgres, googleSheets. Webhook trigger; 12 nodes.