FaunaDB stores data as documents inside collections — rows in tables, equivalently. Each user of the sample app is a User document in a Users collection, and each login method is an Account document in an Accounts collection. Because one User will eventually hold several authentication methods, the model is one-to-many and the reference lives on the Account.

Users and Fweets relate many-to-many, since likes, comments and refweets all tie the two together.

A third collection, Fweetstats, records the interaction between a single User and a single Fweet; it drives the state of the like, comment and refweet icons and tells the UI whether a click on the heart means like or unlike. Fweets sit at the center of the finished model, holding the message, the like/refweet/comment counts and any attached Cloudinary media.

Hashtags are stored as a list of references. Inlining the full hashtag JSON is the route taken by document databases without relations, but it duplicates hashtags across documents and makes searching by tag harder. Comments point the other way: the Comments collection references the Fweet, because one Comment belongs to one Fweet while a Fweet has many comments. A FollowerStats collection stores how much users interact, feeding personalized timelines, but is left aside here.
Project setup
Sign up at the FaunaDB dashboard, create a database, then open the Security tab and create a new key with the Admin role, leaving the current database selected. Copy the key secret from the confirmation page — it is shown only once.

Clone the repository and follow the readme. The provided scripts initialize the app, create the collections and populate the database. The .env.local file should afterwards hold the bootstrap key the script returned, not the admin key.
REACT_APP_LOCAL___BOOTSTRAP_FAUNADB_KEY=<bootstrap key>
Optionally add a Cloudinary cloud name and a public upload preset to the environment (the default preset ml_default can simply be made public) to enable images and videos. Without those variables the media button is inert and the rest of the app still runs.

Front end structure and the JavaScript driver
The app was generated with Create React App and split into pages and components. Pages are top-level components with their own URLs: Login, Register, Home (the feed of Fweets from followed authors), plus User and Tag pages listing Fweets in reverse chronological order. React Router maps URLs to these pages in src/app.js, where a SessionProvider context holds the logged-in user's information.
<Router>
<SessionProvider value={{ state, dispatch }}>
<Layout>
<Switch>
<Route exact path="/accounts/login">
<Login />
</Route>
<Route exact path="/accounts/register">
<Register />
</Route>
<Route path="/users/:authorHandle" component={User} />
<Route path="/tags/:tag" component={Tag} />
<Route path="/">
<Home />
</Route>
</Switch>
</Layout>
</SessionProvider>
</Router>
Home (src/pages/home.js) combines hooks to manage data. Query logic lives in src/fauna/queries, and every database call goes through the query-manager — the piece earmarked to become serverless function calls later. For now calls leave the front end directly, with the sensitive parts protected by ABAC roles and User Defined Functions. FaunaDB behaves as a token-secured API, so there is no connection limit of the kind traditional databases impose.
Connecting the app means installing FaunaDB's JavaScript driver from npm and importing it.
import faunadb from 'faunadb'
A client is then created from a token.
this.client = new faunadb.Client({
secret: token || this.bootstrapToken
})
Writing data with FQL
Fweet creation lives in src/fauna/queries/fweets.js, and each document follows the same JSON shape.
const data = {
data: {
message: message,
likes: 0,
refweets: 0,
comments: 0,
created: Now()
}
}
Now() records the query time so a feed can be sorted chronologically. FaunaDB timestamps every entity for temporal querying, but that timestamp reflects the last update, and a Fweet document is rewritten each time it is liked. For ordering by creation the explicit value is needed. Create() takes the reference from Collection('fweets') to choose the destination collection.
const query = Create(Collection('fweets'), data )
A wrapper function takes a message and hands the query to the database through client.query(). Nothing is sent until that call: queries are assembled beforehand by composing FQL functions, exactly as one would compose functions in the host language.
function createFweet(message, hashtags) {
const data = …
const query = …
return client.query(query)
}
The composition approach extends to custom functions such as CreateHashtags(), defined with FQL elsewhere and called like any built-in.
const data = {
data: {
// ...
hashtags: CreateHashtags(tags),
likes: 0,
// ...
}
Because FQL is invoked from the host language, it works as an embedded DSL: a custom function and a native one are both just functions taking input. The same pattern shows up in community FQL libraries. Note also that a single transaction creates entities in two different collections, so a Fweet cannot exist without its hashtags. FaunaDB stays transactional and consistent across collections, a property that is rare in distributed databases built for scale.
The author is supplied by the database, not the client. Identity() returns a reference to the logged-in document — an Account, kept separate from Users to allow SSO later.

Wrapping it in Get() yields the full document rather than the reference.
Get(Identity())
A Select() then pulls data.user out of the account document into the data JSON.
const data = {
data: {
// ...
hashtags: CreateHashtags(tags),
author: Select(['data', 'user'], Get(Identity())),
likes: 0,
// ...
}
}
The finished query goes to client.query(query). The file src/fauna/queries/fweets.js carries the final version, which composes further logic such as rate limiting into the same transaction.
UDFs, roles and authentication
JavaScript-composed queries would be useless as a security boundary on their own. Two FaunaDB features close the gap: Attribute-Based Access Control (ABAC) and User Defined Functions (UDF). ABAC roles govern which collections and entities a given key or token may touch. UDFs push FQL into the database via CreateFunction(). Once stored, the query is fixed like a stored procedure and the front end can only call it, not alter it.
CreateFunction({
name: 'create_fweet',
body: <your FQL statement>,
})
client.query(
Call(Function('create_fweet'), message, hashTags)
)
As an illustration, the author of a Fweet is never passed in — it is derived from Identity(), so a user cannot post on someone else's behalf. Access to the UDF itself comes from a minimal ABAC role: logged_in_role, whose members are all documents in the Accounts collection, grants those members the privilege of calling the create_fweet UDF.
CreateRole(
name: 'logged_in_role',
privileges: [
{
resource: q.Function('create_fweet'),
actions: {
call: true
}
}
],
membership: [{ resource: Collection('accounts') }],
)
Becoming an Account starts with a new Account document holding credentials beside the rest of the account data (here, the email and the User reference).
return Create(Collection('accounts'), {
credentials: { password: password },
data: {
email: email,
user: Select(['ref'], Var('user'))
}
})
}
Calling Login() on that reference returns a token.
Login(
Match( < Account reference > ,
{ password: password }
)
)
That token lets the client impersonate the Account. Membership in the Accounts collection satisfies logged_in_role, so the token reaches the create_fweet UDF. Two roles bootstrap the flow: bootstrap_role, limited to the login and register UDFs, and logged_in_role, which reaches everything else.
The token from the setup script is a key created under bootstrap_role, and the client built from it can only register or log in. After Login() returns a new token, the app builds a second client with it and gains the other UDFs; logging out restores the bootstrap token. Both the process and more involved role examples live in src/fauna/query-manager.js and src/fauna/setup/roles.js.
Session state in React
React Contexts suit data needed throughout the app, which is why SessionProvider is mounted early. Components read the user with the useContext hook after importing the context.
import SessionContext from '../context/session'
import React, { useContext } from 'react'
// In your component
const sessionContext = useContext(SessionContext)
const { user } = sessionContext.state
The provider receives a value containing the state and a dispatch function. The dispatch function is the point of the context; creating a context with React.createContext() only yields a Provider and a Consumer.
const SessionContext = React.createContext({})
export const SessionProvider = SessionContext.Provider
export const SessionConsumer = SessionContext.Consumer
export default SessionContext
State and dispatch come from React.useReducer, so a reducer is needed — the logic that inspects an action and decides the next context value. Here an action is only a type with a string, and the context stores user information, so a successful login dispatches:
sessionContext.dispatch({ type: 'login', data: e })
Cloudinary for media
FaunaDB holds application data, not image blobs or video data, so media goes to Cloudinary with only a link kept in the Fweet. The Cloudinary script is included in app.js and the upload widget is created in src/components/uploader.js. A cloud name and a public template are required in .env.local; accounts are free and the cloud name sits on the dashboard.
loadScript('https://widget.cloudinary.com/v2.0/global/all.js')
window.cloudinary.createUploadWidget(
{
cloudName: process.env.REACT_APP_LOCAL___CLOUDINARY_CLOUDNAME,
uploadPreset: process.env.REACT_APP_LOCAL___CLOUDINARY_TEMPLATE,
},
(error, result) => {
// ...
}
)
API keys can secure uploads instead, but uploading straight from the front end uses a public template. To create or make one public, open the gear icon, go to the Upload tab, click Add upload preset, or simply edit ml_default and make it public. The widget opens from widget.open() when the media button is clicked, and its styles and fonts can be supplied at creation time to match the app.

const widget = window.cloudinary.createUploadWidget(
{
cloudName: process.env.REACT_APP_LOCAL___CLOUDINARY_CLOUDNAME,
uploadPreset: process.env.REACT_APP_LOCAL___CLOUDINARY_TEMPLATE,
styles: {
palette: {
window: '#E5E8EB',
windowBorder: '#4A4A4A',
tabIcon: '#000000',
// ...
},
fonts: {
After an upload, Cloudinary returns details that are written into the Fweet data. The stored id — Cloudinary's publicId — is then used with the Cloudinary React library in src/components/asset.js to render the feed.
import { Image, Video, Transformation } from 'cloudinary-react'
Using the id rather than a direct URL lets Cloudinary optimize delivery. A video image added this way is scaled down to 600 pixels wide and served as WebM (VP9) to Chrome (482 KB), MP4 (HEVC) to Safari (520 KB), or MP4 (H.264) to browsers supporting neither (821 KB) — all server-side, which improves page load time.
Reading the feed
Fetching the feed is the harder direction. It must bring back Fweets from followed users ordered by time and popularity, each author's profile image and handle, the like/refweet/comment counts, the comments beneath a Fweet, whether the reader has already liked, refweeted or commented on it, and the original Fweet behind any refweet. That spans several collections and demands advanced indexing, so the query starts small: a reference to the collection through Collection(), wrapped in Documents() for all of its document references, then passed to Paginate().
Paginate(Documents(Collection('fweets')))
Paginate() needs an explanation because it changes what a query actually does. Before it, the query describes a hypothetical set of data; Paginate() materializes that description into pages of entities that can be read. FaunaDB insists on it to prevent queries that would drag in every document of a collection — at massive scale that could be millions of documents and an enormous bill. The partial query is kept in a plain JavaScript variable, references, so it can be extended.
const references = Paginate(Documents(Collection('fweets')))
To turn references into documents, map over them — in FQL, a Lambda is just an anonymous function.
const fweets = Map(
references,
Lambda(['ref'], Get(Var('ref')))
)
This is more verbose than SQL, which states what is wanted and leaves the retrieval to the engine. FQL states what and how, so it reads procedurally. The payoff is predictability: because the caller defines the retrieval, the reads a query costs can be worked out without running it, which matters when the database is huge and billed per use. The learning curve is offset by that cost visibility.
Let keeps the query readable as it grows, binding variables that the next binding can reuse immediately.
const fweets = Map(
references,
Lambda(
['ref'],
Let(
{
fweet: Get(Var('ref'))
},
// Just return the fweet for now
Var('fweet')
)
)
)
Adding the author on top of that skeleton is straightforward.
const fweets = Map(
references,
Lambda(
['ref'],
Let(
{
fweet: Get(Var('ref')),
author: Get(Select(['data', 'author'], Var('fweet')))
},
{ fweet: Var('fweet'), author: Var('author') }
)
)
)
No join was written, yet Users and Fweets have been joined. src/fauna/queries/fweets.js holds the final query and further examples.
Elsewhere in the repository
Alongside the finished queries, src/fauna/queries/fweets.js demonstrates complex matching and sorting against FaunaDB indexes, with the indexes themselves created in src/fauna/setup/fweets.js. Three access patterns are implemented: Fweets by popularity and time, by handle, and by tag.

Fetching by popularity and time is the most interesting of the three, since it sorts Fweets by a decaying popularity derived from how users interact, and feeds that back into personalized timelines. src/fauna/queries/search.js implements autocomplete over FaunaDB indexes and index bindings to search authors and tags; because an index can span multiple collections, one index supports autocomplete across both Users and Tags.

These examples exist because flexible, powerful indexes combined with relations are rare in scalable distributed databases. Without both, the access patterns must be known in advance, and the schema becomes a liability once business logic has to follow evolving use cases. With indexing available at any time, a missing access pattern just means adding a range, term, or composite index later, with no need to code around eventual consistency.



