Data Tables give you a flexible, secure way to store structured content right inside Memberstack — no separate database required. They work great for blogs, marketplaces, directories, courses, and more, and they respect your member authentication and table access rules out of the box.
This guide balances a friendly, no-code tone with accurate, developer-level details from the latest SDK + server behavior. It includes copy-paste examples that work in Webflow or any front-end using @memberstack/dom.
What are Data Tables?
Think of Data Tables as your own custom spreadsheet inside Memberstack.
- Table — A collection (e.g.,
articles,products). - Record — A row in a table (e.g., one article).
- Field — A column (e.g.,
title,price,published).
Perfect for builders! Data Tables work great with AI tools like ChatGPT, Claude, and Cursor. Share the examples below and ask the AI to adapt them to your exact use case.
What Can You Build?
- Blog or News Site — Store articles, comments, author profiles
- Job Board — Save job listings, applications, company profiles
- Course Platform — Track lessons, student progress, quiz scores
- Marketplace — Manage products, reviews, seller information
- Community Forum — Store posts, replies, reputation
- Event Platform — Handle registrations, schedules, attendees
Quick Start
Using @memberstack/dom (vanilla JS)
import MemberstackDOM from '@memberstack/dom'
const memberstack = MemberstackDOM.init({
publicKey: 'pk_test_123',
appId: 'app_123',
})
Using Webflow
The DOM methods are exposed globally via window.$memberstackDom:
const memberstack = window.$memberstackDom
Service Health / Feature Flag
If your backend sets the environment variable DISABLE_DATA_TABLES to a truthy value, all Data Tables routes return 503 with the message "Data table feature is temporarilly offline." (that exact string).
Getting Started — Basics
Enable Data Tables
- Log into your Memberstack dashboard.
- Go to the Data Tables page in the left navigation.
Example Table (Blog Posts)
| Field Name | Field Type | Required? | What It Stores |
|---|---|---|---|
| title | TEXT | Yes | The blog post title |
| content | TEXT | Yes | The main article content |
| published | BOOLEAN | No | Is it live? (true/false) |
| publishedDate | DATE | No | When it was published |
Note: Field objects in server responses do not include a unique property. If you see unique in SDK typings, the server does not return it.
API Overview (DOM Methods)
| Method | Best For | Key Notes |
|---|---|---|
getDataTables() | List tables + fields | Includes recordCount (access-filtered) |
getDataTable({ table }) | Get one table definition | Same shape as an item in getDataTables().data.tables[] |
createDataRecord({ table, data }) | Add a record | Server sets creator automatically; any memberId you pass is ignored |
getDataRecord({ table, recordId }) | Fetch a single record by ID | SDK unwraps server response; returns { data: record } |
updateDataRecord({ recordId, data }) | Edit a record | Supports connect/disconnect for many relations |
deleteDataRecord({ recordId }) | Remove a record | Returns deleted ID |
queryDataRecords({ table, query }) | Advanced filtering, relationships, counts, pagination | Preferred for listing & filtering; supports findMany and findUnique |
getDataRecords({...}) | Basic listing only | Supports: tableKey, created date range, sort, limit, cursor. Use queryDataRecords for anything complex. |
List Your Tables
getDataTables()
const { data } = await memberstack.getDataTables()
data.tables.forEach((t) => {
console.log(`${t.key} → ${t.recordCount} records`)
})
getDataTable({ table })
const { data } = await memberstack.getDataTable({ table: 'articles' })
console.log(data.name, data.fields.length)
Core Operations (CRUD)
Create — createDataRecord({ table, data })
const { data } = await memberstack.createDataRecord({
table: 'cars',
data: { make: 'Tesla', model: 'Model 3', year: 2022 },
})
console.log('Created record:', data.id)
console.log('Active member owns it?', data.activeMemberOwnsIt) // true/false
Notes:
- The server automatically sets
createdByMemberIdbased on the authenticated user. If you passmemberId, it is ignored. - Responses may include
_internalUseOnly; it’s safe to ignore.
Read One — getDataRecord({ table, recordId })
const { data } = await memberstack.getDataRecord({
table: 'cars',
recordId: 'rec_abc123',
})
console.log(data.data.make, data.data.model)
The SDK issues a findUnique request under the hood and unwraps the server response to { data: record } for convenience.
Update — updateDataRecord({ recordId, data })
// Simple fields
await memberstack.updateDataRecord({
recordId: 'rec_abc123',
data: { mileage: 12500 },
})
// Record-to-record many (REFERENCE_MANY)
await memberstack.updateDataRecord({
recordId: 'rec_abc123',
data: {
tags: {
connect: [{ id: 'rec_tag_electric' }],
disconnect: [{ id: 'rec_tag_gas' }],
},
},
})
// Member many (MEMBER_REFERENCE_MANY)
await memberstack.updateDataRecord({
recordId: 'rec_abc123',
data: { favoritedBy: { connect: [{ self: true }] } },
})
Delete — deleteDataRecord({ recordId })
const { data } = await memberstack.deleteDataRecord({ recordId: 'rec_abc123' })
console.log('Deleted record ID:', data.id)
Advanced Querying (queryDataRecords)
Prefer this method for lists, filters, relationships, counts, and cursor-based pagination.
Request Shape
type QueryDataRecordsParams = {
table: string
query: {
// Choose exactly one:
findMany?: {
where?: object // supports AND / OR / NOT and operators like equals, in, gt, contains, ...
include?: object // simple relations; many-to-many not allowed here
select?: object
orderBy?: object // e.g., { createdAt: 'asc' }
take?: number // 1..100
skip?: number // 0..10000
after?: string // cursor (internalOrder)
_count?: boolean | { select: Record<string, boolean> }
}
findUnique?: {
where: { id: string }
include?: object // may include many-to-many with per-include pagination
// no top-level take/skip/after/_count here
}
}
}
findMany — filters, simple includes, counts
const { data } = await memberstack.queryDataRecords({
table: 'articles',
query: {
findMany: {
where: {
published: { equals: true },
views: { gt: 100 },
category: { in: ['tech', 'tutorial'] },
},
include: {
author: true, // REFERENCE
_count: { select: { comments: true, likes: true } },
},
orderBy: { createdAt: 'desc' },
take: 20,
},
},
})
console.log('records:', data.records.length)
console.log('first author id:', data.records[0]?.data.author?.id)
console.log('first counts:', data.records[0]?._count)
Rules: In findMany, includes support simple relations (REFERENCE, MEMBER_REFERENCE) and _count.select. Many-to-many includes (REFERENCE_MANY, MEMBER_REFERENCE_MANY) are not allowed here — use findUnique instead.
findUnique — include many-to-many (with per-include pagination)
const { data } = await memberstack.queryDataRecords({
table: 'articles',
query: {
findUnique: {
where: { id: 'rec_article_123' },
include: {
tags: { take: 10 }, // REFERENCE_MANY (records) → pagination.endCursor is numeric (internalOrder)
likedBy:{ take: 25 }, // MEMBER_REFERENCE_MANY (members) → pagination.endCursor is a string (createdAt)
},
},
},
})
const article = data.record
console.log('Tags page 1:', article.data.tags.records)
console.log('Liked by page 1:', article.data.likedBy.records)
Include Pagination Rules
- REFERENCE_MANY / REVERSE_REFERENCE_MANY: ordered by
internalOrderasc; cursorafteris numeric; responsepagination.endCursoris numeric. - MEMBER_REFERENCE_MANY: ordered by
createdAtasc; cursorafteris an ISO date string; responseendCursoris a string. Member objects include email via member.auth. - Defaults: each include
takedefaults to 100; server fetches +1 internally to computehasMore. - You may use
skipas an alternative toafter, but not both together in a single include.
Top-level Pagination (findMany)
// First page
let { data } = await memberstack.queryDataRecords({
table: 'products',
query: { findMany: { orderBy: { createdAt: 'desc' }, take: 10 } },
})
console.log('First page:', data.records)
// Next page using cursor
if (data.pagination?.hasMore && data.pagination.endCursor) {
const next = await memberstack.queryDataRecords({
table: 'products',
query: {
findMany: {
orderBy: { createdAt: 'desc' },
take: 10,
after: String(data.pagination.endCursor), // internalOrder cursor
},
},
})
console.log('Next page:', next.data.records)
}
Count-only Queries
const { data } = await memberstack.queryDataRecords({
table: 'articles',
query: { findMany: { where: { published: { equals: true } }, _count: true } },
})
console.log('Total published articles:', data._count)
Basic Listing (legacy/simple) — getDataRecords
For simple lists you can use getDataRecords. It supports only: tableKey, createdAfter, createdBefore, sortBy, sortDirection, limit, after.
const response = await memberstack.getDataRecords({
tableKey: 'blog_posts',
limit: 10,
sortBy: 'createdAt',
sortDirection: 'DESC',
})
console.log('Records:', response.data.records)
Recommendation: Use queryDataRecords for filters, includes, counts, and modern pagination patterns.
Authentication & Access Control
How Auth Works
All endpoints enforce table-level access rules (createRule, readRule, updateRule, deleteRule). For reads, the server automatically applies access filters; you cannot override them by passing a memberId.
Member Ownership
// When a member creates a record, createdByMemberId is set automatically
const { data } = await memberstack.createDataRecord({
table: 'user_posts',
data: { title: 'My Post' },
})
console.log(data.activeMemberOwnsIt) // true or false
Access Rules
| Rule Type | Controls | Options |
|---|---|---|
| Create Rule | Who can add new records | PUBLIC, MEMBERS_ONLY, ADMIN_ONLY |
| Read Rule | Who can view records | PUBLIC, MEMBERS_ONLY, ADMIN_ONLY |
| Update Rule | Who can edit records | PUBLIC, MEMBERS_ONLY, MEMBER_OWNER, ADMIN_ONLY |
| Delete Rule | Who can remove records | PUBLIC, MEMBERS_ONLY, MEMBER_OWNER, ADMIN_ONLY |
Common Access Patterns
Public Blog:
- Create: MEMBERS_ONLY
- Read: PUBLIC
- Update: MEMBER_OWNER
- Delete: MEMBER_OWNER
Private User Data:
- Create: MEMBERS_ONLY
- Read: MEMBER_OWNER
- Update: MEMBER_OWNER
- Delete: MEMBER_OWNER
Admin-Managed Content:
- Create: ADMIN_ONLY
- Read: PUBLIC
- Update: ADMIN_ONLY
- Delete: ADMIN_ONLY
Using Data Tables in Your Website
Important: You’ll add a bit of JavaScript. Feel free to paste these into an AI and ask it to customize the code to your site.
Example: Show Latest Blog Posts
async function showBlogPosts() {
const memberstack = window.$memberstackDom
const response = await memberstack.queryDataRecords({
table: 'blog_posts',
query: {
findMany: {
where: { published: { equals: true } },
orderBy: { createdAt: 'desc' },
take: 10,
},
},
})
response.data.records.forEach((post) => {
console.log(post.data.title)
console.log(post.data.content)
})
}
Example: Create a New Blog Post
async function createPost(title, content) {
const memberstack = window.$memberstackDom
const { data } = await memberstack.createDataRecord({
table: 'blog_posts',
data: {
title,
content,
published: false, // Start as draft
},
})
console.log('Post created!', data.id)
}
Field Types
| Field Type | Use For | Examples |
|---|---|---|
| TEXT | Any text or words | Names, descriptions, URLs, emails |
| NUMBER | Numbers (whole or decimal) | Prices, quantities, ratings, ages |
| BOOLEAN | Yes/No choices | Published? Featured? Active? |
| DATE | Dates and times | Publish date, event date, deadline |
| JSON | Complex data (lists, settings) | Tags, preferences, multiple images |
| REFERENCE | Link to one record in another table | Author ID, Category ID |
| REFERENCE_MANY | Link to multiple records in another table | Multiple tags, categories |
| MEMBER_REFERENCE | Link to one member | Post author, assigned user |
| MEMBER_REFERENCE_MANY | Link to multiple members | Liked by, participants |
Advanced: Includes, Many-to-Many, Counts
Includes & Counts (findMany)
// Get articles with author information and counts
const { data } = await memberstack.queryDataRecords({
table: 'articles',
query: {
findMany: {
where: { published: { equals: true } },
include: {
author: true, // Include author data (REFERENCE)
_count: { select: { comments: true, likes: true } },
},
orderBy: { createdAt: 'desc' },
take: 20,
},
},
})
console.log('Articles with authors:', data.records)
Many-to-Many (findUnique)
// Get one article with its tags and members who liked it
const { data } = await memberstack.queryDataRecords({
table: 'articles',
query: {
findUnique: {
where: { id: 'rec_article_123' },
include: {
tags: { take: 10 }, // REFERENCE_MANY
likedBy:{ take: 25 }, // MEMBER_REFERENCE_MANY
},
},
},
})
const article = data.record
console.log('Tags:', article.data.tags.records)
console.log('Liked by:', article.data.likedBy.records)
SDK note: getDataRecord (the single-record helper) does not currently accept include. To fetch many-to-many related sets, use queryDataRecords with findUnique or run separate queries.
Query Operators
Where clauses support a Prisma-like structure with logical AND/OR/NOT and common operators:
| Operator | What It Does | Example |
|---|---|---|
equals | Exact match | published: { equals: true } |
gt | Greater than | views: { gt: 100 } |
gte | Greater than or equal | price: { gte: 50 } |
lt | Less than | stock: { lt: 10 } |
lte | Less than or equal | rating: { lte: 3 } |
contains | Substring match | title: { contains: 'JavaScript' } |
in | Value in list | status: { in: ['active', 'pending'] } |
not | Negation | featured: { not: true } |
Rate Limits
- Global (all client REST routes): 200 requests per 30 seconds per IP
- Data Tables — Reads: 25 requests per second per IP
Applies to: GET/v1/data-tables, GET/v1/data-tables/:tableKey, POST/v1/data-records/query, GET/v1/data-records - Data Tables — Creates: 10 requests per minute per IP
Applies to: POST/v1/data-records - Data Tables — Writes: 30 requests per minute per IP
Applies to: PUT/DELETE/v1/data-records/:recordId
Most sites will never hit these limits. If you do, batch reads with pagination and debounce UI actions.
Troubleshooting
“Access Denied”
Check the table’s access rules and ensure the user is logged in if required.
“Required field missing”
Provide all required fields when creating records.
“Table not found”
Double-check the table key (it’s case-sensitive).
“Rate limit exceeded”
Wait briefly and retry. Consider pagination and request consolidation.
“Data table feature is temporarilly offline.” (503)
The feature flag is enabled on your server (DISABLE_DATA_TABLES). Turn it off to restore service.
Complete Examples
Advanced Blog with Relationships
async function createAdvancedPost() {
const memberstack = window.$memberstackDom
try {
// Create the post
const post = await memberstack.createDataRecord({
table: 'blog_posts',
data: {
title: 'Advanced Data Tables Guide',
content: 'Learn about relationships and advanced querying...',
published: true,
},
})
// Add tags using many-to-many relationship
await memberstack.updateDataRecord({
recordId: post.data.id,
data: {
tags: {
connect: [{ id: 'rec_tag_tutorial' }, { id: 'rec_tag_advanced' }],
},
},
})
console.log('Advanced post created with tags!')
} catch (error) {
console.error('Error creating post:', error)
}
}
Display Posts with Author and Counts
// Show blog posts with author info and engagement counts
async function displayAdvancedPosts() {
const memberstack = window.$memberstackDom
try {
const response = await memberstack.queryDataRecords({
table: 'blog_posts',
query: {
findMany: {
where: { published: { equals: true } },
include: {
author: true,
_count: { select: { comments: true, likes: true, tags: true } },
},
orderBy: { createdAt: 'desc' },
take: 10,
},
},
})
const container = document.getElementById('posts-container')
response.data.records.forEach((post) => {
const postHTML = `
<article>
<h3>${post.data.title}</h3>
<p>By: ${post.data.author?.data?.name || 'Unknown Author'}</p>
<p>${(post.data.content || '').substring(0, 200)}...</p>
<div>
<span>${post._count?.likes ?? 0} likes</span>
<span>${post._count?.comments ?? 0} comments</span>
<span>${post._count?.tags ?? 0} tags</span>
</div>
</article>
`
container.innerHTML += postHTML
})
} catch (error) {
console.error('Error loading posts:', error)
}
}
window.addEventListener('load', displayAdvancedPosts)
Best Practices
Start Simple
Begin with one table and a few fields. Get that working, then expand.
Choose the Right Field Types
| If you’re storing… | Use this type | Not this type |
|---|---|---|
| Age, price, quantity | NUMBER | TEXT |
| Yes/no, true/false, on/off | BOOLEAN | TEXT |
| Dates, timestamps | DATE | TEXT |
| Lists, settings, multiple values | JSON | TEXT |
| Link to one other record | REFERENCE | TEXT |
| Link to multiple records | REFERENCE_MANY | JSON |
Performance Tips
- Use
queryDataRecordsfor complex filtering (it’s more efficient and expressive). - Prefer cursor-based pagination (
after) overskip. - Include only what you need to keep payloads small.
- Use
_countfor stats when you only need totals.
Security Tips
- Validate user input before sending to Data Tables.
- Choose appropriate access rules — avoid PUBLIC unless needed.
- Test with different roles to confirm permissions.
Server Reference & Notes
- GET
/v1/data-recordssupports only:tableKey,createdAfter,createdBefore,sortBy,sortDirection,limit,after. For field-level filtering or relationship includes, usePOST /v1/data-records/query. - POST
/v1/data-records/queryrequires atablein the body and eitherfindManyorfindUniqueat the top level. - In responses, server converts any
BigIntvalues toNumberbefore JSON serialization. - SDK unwrapping: For
getDataRecord, the server returns{ data: { record } }and the SDK returns{ data: record }.
Pro Tip: Start simple! Create one table, add a few records, and display them. Once that works, layer on includes, counts, and pagination.
Comments
0 comments
Please sign in to leave a comment.