Data Tables - Store Custom Data in Your Memberstack App

Article author
Josh Lopez
  • Updated

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

  1. Log into your Memberstack dashboard.
  2. Go to the Data Tables page in the left navigation.

Example Table (Blog Posts)

Field NameField TypeRequired?What It Stores
titleTEXTYesThe blog post title
contentTEXTYesThe main article content
publishedBOOLEANNoIs it live? (true/false)
publishedDateDATENoWhen 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)

MethodBest ForKey Notes
getDataTables()List tables + fieldsIncludes recordCount (access-filtered)
getDataTable({ table })Get one table definitionSame shape as an item in getDataTables().data.tables[]
createDataRecord({ table, data })Add a recordServer sets creator automatically; any memberId you pass is ignored
getDataRecord({ table, recordId })Fetch a single record by IDSDK unwraps server response; returns { data: record }
updateDataRecord({ recordId, data })Edit a recordSupports connect/disconnect for many relations
deleteDataRecord({ recordId })Remove a recordReturns deleted ID
queryDataRecords({ table, query })Advanced filtering, relationships, counts, paginationPreferred for listing & filtering; supports findMany and findUnique
getDataRecords({...})Basic listing onlySupports: 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 createdByMemberId based on the authenticated user. If you pass memberId, 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 internalOrder asc; cursor after is numeric; response pagination.endCursor is numeric.
  • MEMBER_REFERENCE_MANY: ordered by createdAt asc; cursor after is an ISO date string; response endCursor is a string. Member objects include email via member.auth.
  • Defaults: each include take defaults to 100; server fetches +1 internally to compute hasMore.
  • You may use skip as an alternative to after, 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 TypeControlsOptions
Create RuleWho can add new recordsPUBLIC, MEMBERS_ONLY, ADMIN_ONLY
Read RuleWho can view recordsPUBLIC, MEMBERS_ONLY, ADMIN_ONLY
Update RuleWho can edit recordsPUBLIC, MEMBERS_ONLY, MEMBER_OWNER, ADMIN_ONLY
Delete RuleWho can remove recordsPUBLIC, 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 TypeUse ForExamples
TEXTAny text or wordsNames, descriptions, URLs, emails
NUMBERNumbers (whole or decimal)Prices, quantities, ratings, ages
BOOLEANYes/No choicesPublished? Featured? Active?
DATEDates and timesPublish date, event date, deadline
JSONComplex data (lists, settings)Tags, preferences, multiple images
REFERENCELink to one record in another tableAuthor ID, Category ID
REFERENCE_MANYLink to multiple records in another tableMultiple tags, categories
MEMBER_REFERENCELink to one memberPost author, assigned user
MEMBER_REFERENCE_MANYLink to multiple membersLiked 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:

OperatorWhat It DoesExample
equalsExact matchpublished: { equals: true }
gtGreater thanviews: { gt: 100 }
gteGreater than or equalprice: { gte: 50 }
ltLess thanstock: { lt: 10 }
lteLess than or equalrating: { lte: 3 }
containsSubstring matchtitle: { contains: 'JavaScript' }
inValue in liststatus: { in: ['active', 'pending'] }
notNegationfeatured: { 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 typeNot this type
Age, price, quantityNUMBERTEXT
Yes/no, true/false, on/offBOOLEANTEXT
Dates, timestampsDATETEXT
Lists, settings, multiple valuesJSONTEXT
Link to one other recordREFERENCETEXT
Link to multiple recordsREFERENCE_MANYJSON

Performance Tips

  • Use queryDataRecords for complex filtering (it’s more efficient and expressive).
  • Prefer cursor-based pagination (after) over skip.
  • Include only what you need to keep payloads small.
  • Use _count for 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-records supports only: tableKey, createdAfter, createdBefore, sortBy, sortDirection, limit, after. For field-level filtering or relationship includes, use POST /v1/data-records/query.
  • POST /v1/data-records/query requires a table in the body and either findMany or findUnique at the top level.
  • In responses, server converts any BigInt values to Number before 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.

Was this article helpful?

Comments

0 comments

Please sign in to leave a comment.