← All posts
·14 min read

Claude Code with Fauna: Serverless DB and FQL v10 Patterns

Claude CodeFaunaDatabaseServerless
Claude Code with Fauna: Serverless distributed database

Why Fauna without CLAUDE.md mixes FQL versions and breaks queries

Fauna is the strongly consistent serverless database available to teams that want SQL-like guarantees without managing the SQL itself in 2026. The database uses FQL (Fauna Query Language) v10, which is a typed, functional query language that runs as transactions by default. The version matters. FQL v10 looks similar to v4 in places but has fundamentally different syntax, type system, and execution semantics. The version mixup is the trap when Claude Code touches it. Without explicit constraints, Claude generates code that calls v4 functions like q.Create() from the old faunadb package alongside v10 syntax from the new fauna package, producing files that import from both libraries and runtime errors that look like syntax bugs.

The most common Claude defaults that hurt Fauna projects: importing from the deprecated faunadb package instead of the modern fauna package, using uppercase function-style calls like Create(Collection("users"), { data: ... }) from v4 instead of the v10 method syntax users.create({ ... }), defining indexes as separate top-level objects instead of as part of the collection definition, treating document references as opaque strings instead of typed Ref values, and writing FQL queries as JavaScript template literals without using the fql tagged template that handles parameter escaping.

This guide covers the CLAUDE.md configuration that locks Claude Code into Fauna FQL v10: the new fauna package as the only import source, method-syntax queries on collection objects, indexes declared inline with collection schemas, refs typed properly through the FQL type system, and the tagged-template parameter pattern that prevents query injection. If you are building the API layer that wraps Fauna, Claude Code with Next.js covers the route handler patterns. For schema design generally, Claude Code with database design shows the broader modelling approach that pairs with Fauna's document collections.

The Fauna CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Fauna integration it needs to declare: the package version (and the deprecated package never to import), the FQL v10 query patterns, the schema definition approach, the role and auth model, and the hard rules that block the mistakes Claude makes most often.

# Fauna rules

## Stack
- fauna ^2.x (NOT faunadb, that is deprecated v4)
- TypeScript 5.x strict, Node.js 20.x
- FQL v10 ONLY, never mix v4 syntax
- Fauna CLI ^3.x for schema deploys (fauna schema push)

## Package (HARD RULE)
- ALWAYS import from "fauna", NEVER from "faunadb"
- If a file already imports from "faunadb", flag and migrate it
- Available exports: Client, fql, Module, NamedDocument, Page, NullDocument

## Schema (declared in .fsl files, not in JS)
- Schema lives in: schema/*.fsl
- Collections: collection MyCollection { ... }
- Functions: function myFunction(args) : ReturnType { ... }
- Roles: role MyRole { ... privileges { ... } }
- Deploy with: fauna schema push --secret $FAUNA_ADMIN_KEY

## Query pattern (FQL v10)
- Use fql`...` tagged template for ALL queries
- Parameters interpolate via ${value} inside fql tagged templates
- NEVER concatenate strings into FQL queries
- NEVER use the deprecated q. namespace (q.Create, q.Match, q.Lambda, etc)

Example:
  const result = await client.query(fql`
    Users.byEmail(${email}).first()
  `);

## Collection methods (v10 syntax)
- .create({ ...data }): insert a new document
- .where(predicate): filter as a function
- .byEmail(email): use a defined index
- .firstWhere(predicate): single match
- .all(): all documents in the collection
- NEVER use Create(), Update(), Get() function-style calls (those are v4)

## Refs and IDs
- Document IDs: strings (auto-generated unless you supply one)
- References across collections: { user: Users.byId(${userId}) }
- Dereferencing: result.user is a NamedDocument with .data
- NEVER store user IDs as plain strings if a Ref is appropriate

## Auth and roles
- Server keys: for trusted backend operations only
- Client keys: scoped, embed in frontend ONLY with a restrictive role
- ABAC: defined in roles, NEVER bypass with admin keys in client code
- Function-bound roles: role Public { privileges { call publicSearch } }

## Hard rules
- NEVER import from faunadb (the v4 package)
- NEVER use q.<anything> syntax (v4 namespace)
- NEVER hardcode FAUNA_ADMIN_KEY or any server key in source
- NEVER build queries by string concatenation
- ALWAYS handle the NullDocument case for byId lookups
- ALWAYS use the FaunaDB Client singleton, NEVER instantiate per request

Four rules here prevent the majority of production failures Claude generates without them.

The package source rule is the single most important constraint. The Fauna ecosystem has two npm packages: faunadb (v4, deprecated, still in tutorials all over the internet) and fauna (v10, current, the only one you should use). Claude's training data contains both. Without the rule, Claude writes a file that imports { Client, q } from "faunadb" and then mixes v10 method syntax in queries, producing TypeScript that compiles but throws at runtime. Pinning to fauna only eliminates the mixup.

The tagged template rule prevents query injection and parameter type errors. The fql tagged template handles type-aware parameter substitution. client.query(fql\Users.byEmail(${email}).first()`)substitutesemailwith a properly escaped value.client.query("Users.byEmail(" + email + ").first()")` is a string injection bug waiting for an apostrophe in an email. Claude defaults to template literals or concatenation when not directed to the tagged template form.

The NullDocument handling rule prevents a confusing class of runtime errors. Fauna's byId queries return either a NamedDocument (success) or a NullDocument (not found). Both have the same TypeScript surface in many cases, so calling .data on a NullDocument is allowed at compile time but throws at runtime. The CLAUDE.md rule forces explicit .exists checks before accessing .data on a lookup result.

Installation and client setup

Install the modern Fauna package:

npm install fauna

Add the connection secret to your environment:

# .env.local
FAUNA_SECRET=fnAE_your_database_admin_key

Create the singleton client:

// src/lib/fauna.ts
import { Client, fql } from "fauna";

if (!process.env.FAUNA_SECRET) {
  throw new Error("FAUNA_SECRET is not defined");
}

export const fauna = new Client({
  secret: process.env.FAUNA_SECRET,
});

export { fql };

Then export from the singleton everywhere:

// src/lib/users.ts
import { fauna, fql } from "./fauna";

export async function getUserByEmail(email: string) {
  const result = await fauna.query<{
    id: string;
    email: string;
    name: string;
  } | null>(fql`
    Users.byEmail(${email}).first()
  `);

  return result.data;
}

The fql import is re-exported from the singleton module so every file uses the same instance. This sounds like over-engineering but it matters for one practical reason. Future versions of fauna may attach metadata or interceptors to specific client instances. Centralising the import lets you swap that behaviour in one place. Claude tends to instantiate new Client() in every file that needs Fauna; the singleton rule prevents that drift.

Schema in .fsl files

Fauna v10 introduced declarative schemas in FSL (Fauna Schema Language). Schemas live in .fsl files and deploy through the CLI. The schema declares collections, their fields, indexes, computed values, and access rules.

The basic collection:

// schema/users.fsl

collection Users {
  // Required fields
  email: String
  name: String

  // Optional fields with defaults
  createdAt: Time = Time.now()
  role: String = "member"

  // Computed field
  compute displayName: String = .name + " <" + .email + ">"

  // Index for lookups by email
  index byEmail {
    terms [.email]
  }

  // Index for sorting recent users
  index recent {
    values [desc(.createdAt), .id]
  }

  // Uniqueness constraint
  unique [.email]
}

A second collection with a reference to the first:

// schema/posts.fsl

collection Posts {
  title: String
  body: String
  author: Ref<Users>      // typed reference to a Users document
  publishedAt: Time?      // nullable

  createdAt: Time = Time.now()

  // Compound index: list posts by author, newest first
  index byAuthor {
    terms [.author]
    values [desc(.createdAt), .id]
  }
}

Deploy the schema with the CLI:

fauna schema push --secret $FAUNA_ADMIN_KEY

The schema push is idempotent and incremental. Re-running it after a small edit applies only the diff. For destructive changes (dropping a field, deleting a collection), Fauna prompts for explicit confirmation.

Add a schema section to CLAUDE.md:

## Schema files

- Location: schema/<name>.fsl, one file per collection or related group
- Collections: collection Name { field: Type, index byField { terms [.field] } }
- References: Ref<TargetCollection> for foreign keys, NEVER String
- Indexes: declared INSIDE the collection, NEVER as separate top-level objects
- Index terms = WHERE clause fields, values = ORDER BY fields
- Compute fields: compute name: Type = expression (read-only derived)
- Uniqueness: unique [.field] inside the collection
- Deploy: fauna schema push, ALWAYS in dry-run mode first to see the diff

The terms vs values distinction is the most subtle part of index design. Terms are the fields the index lets you filter by (the equivalent of WHERE in SQL). Values are the fields the index orders by and returns in pages (the equivalent of ORDER BY and SELECT). An index with terms [.email] lets you call Users.byEmail("x@example.com"). An index with values [desc(.createdAt), .id] lets you paginate sorted results without specifying any filter. Claude often puts the wrong fields in the wrong section because the FSL syntax does not enforce semantic correctness.

FQL v10 query patterns

The query method on collections produces results without explicit Match/Get/Map chains:

// Get one user by email
const user = await fauna.query<{ id: string; email: string; name: string }>(fql`
  Users.byEmail(${email}).first()
`);

// List the 20 most recent users
const recent = await fauna.query<{ data: User[] }>(fql`
  Users.recent.take(20)
`);

// Filter with a predicate
const admins = await fauna.query<{ data: User[] }>(fql`
  Users.where(.role == "admin").take(50)
`);

// Update a document by id
await fauna.query(fql`
  Users.byId(${userId})!.update({ role: "admin" })
`);

Three things matter about these queries.

First, the byEmail(${email}) form uses the index named byEmail that you declared on the collection. The index is a method on the collection, not a separate identifier. If you rename the index in the FSL schema, every call site shows up as a TypeScript error after the schema push.

Second, the .first() method returns the first matching document or null. The .take(N) method returns a page of up to N documents. Without either, the query returns the index iterator without materialising results, which is rarely what you want from a route handler.

Third, the ! after byId(${userId}) is the non-null assertion. It says "this query throws if no document with that id exists, treat the return as a guaranteed document". Without it, the return type is Document | NullDocument and you must handle both. The ! is appropriate when the calling code has already verified the id (for example, when the id came from a freshly issued JWT). Otherwise, handle both cases explicitly.

A typical create + read pattern:

// src/lib/posts.ts
import { fauna, fql } from "./fauna";

interface NewPost {
  title: string;
  body: string;
  authorId: string;
}

export async function createPost(input: NewPost) {
  const result = await fauna.query<{
    id: string;
    title: string;
    createdAt: string;
  }>(fql`
    Posts.create({
      title: ${input.title},
      body: ${input.body},
      author: Users.byId(${input.authorId})
    }) {
      id,
      title,
      createdAt
    }
  `);

  return result.data;
}

The trailing { id, title, createdAt } is the projection. It selects which fields come back in the response. Without a projection, FQL returns the full document, which can include large bodies and any related documents you dereferenced. The projection is roughly equivalent to a SELECT list in SQL and matters for response payload size.

Add a queries section to CLAUDE.md:

## Query patterns

- ALWAYS use fql tagged template, NEVER plain strings
- Single document: .first(), .firstWhere(predicate)
- Multiple documents: .take(N), use pagination cursors for > 100 results
- Filtering: .where(.field == value), use indexes for high-cardinality fields
- Updates: byId(${id})!.update({ ... }) (non-null) or .update if it exists
- Projections: trailing { field1, field2 } block to limit response shape
- ALWAYS check the NullDocument case for byId lookups without !
- NEVER call .toArray() on an unbounded query, use .take(N) with N <= 100

Transactions and consistency

Every Fauna query is a transaction by default. Multiple operations in one fql block execute atomically. This is the property that differentiates Fauna from eventually-consistent stores and makes it useful for financial and ordering operations where partial writes are unacceptable.

// Transfer credits between two users atomically
await fauna.query(fql`
  let sender = Users.byId(${senderId})!
  let receiver = Users.byId(${receiverId})!
  let amount = ${transferAmount}

  if (sender.credits < amount) {
    abort("insufficient credits")
  }

  sender.update({ credits: sender.credits - amount })
  receiver.update({ credits: receiver.credits + amount })

  Transfers.create({
    sender: sender,
    receiver: receiver,
    amount: amount,
    occurredAt: Time.now()
  })
`);

If any of the four statements fail, the entire transaction rolls back. The abort() call exits the transaction with a custom error. This pattern replaces the application-level try/catch + rollback you would write with a SQL transaction. Claude generates the equivalent JavaScript pattern when not directed to the FQL transaction form, splitting it into four separate client.query() calls that lose the atomicity.

Add a transactions section to CLAUDE.md:

## Transactions

- Every fql`...` block IS a transaction, no separate begin/commit needed
- Multiple operations in one block execute atomically
- abort("reason") rolls back the entire transaction
- NEVER split a logical transaction into multiple client.query() calls
- ALWAYS keep the transaction body small (Fauna has a 30-second default timeout)
- For long-running work: split into transaction + queue, never one giant transaction

For the broader patterns around webhook handlers that read from and write to Fauna, Claude Code with Stripe covers the idempotency keys that pair with Fauna transactions.

Roles, keys, and authentication

Fauna's auth model is attribute-based access control (ABAC). Roles declare which collections and functions a key can call, with optional row-level predicates. The CLI deploys roles alongside schema.

A read-only public role:

// schema/roles.fsl

role Public {
  // Allow calling the publicSearch function (defined elsewhere)
  privileges [
    {
      resource: Function("publicSearch"),
      actions: { call: true }
    }
  ]
}

A user-scoped role that limits reads to the user's own documents:

role SignedInUser {
  membership Users  // tokens issued to Users documents take this role

  privileges [
    {
      resource: Posts,
      actions: {
        read: true,
        create: true,
        write: (oldDoc, newDoc) => oldDoc.author == Query.identity()
      }
    }
  ]
}

The write: predicate is a function from (oldDoc, newDoc) to a boolean. It runs server-side for every write attempt. Query.identity() returns the document that the calling token was issued to. The combination means a signed-in user can update only the posts they wrote, enforced at the database level instead of in the application code.

Issue a token for a user when they log in:

import { fauna, fql } from "./fauna";

export async function issueUserToken(userId: string) {
  const result = await fauna.query<{ secret: string }>(fql`
    Users.byId(${userId})!.tokens.create()
  `);

  return result.data.secret;
}

The token secret is what you return to the client. The client then uses that secret as the FAUNA_SECRET in its own client instance, and every query it issues runs under the SignedInUser role with Query.identity() resolving to that user.

Add an auth section to CLAUDE.md:

## Auth and roles

- Server admin key: backend only, NEVER ship to frontend
- Per-user tokens: issued via byId!.tokens.create(), returned to client
- Client uses the user token as its FAUNA_SECRET
- Roles enforce permissions at the database level
- Membership Users in a role grants that role to tokens issued from Users
- Predicates (oldDoc, newDoc) => ... run server-side for fine-grained checks
- Query.identity() inside predicates resolves to the calling token's owner

Common Claude Code mistakes with Fauna

Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.

1. Wrong package import

Claude generates: import faunadb, { Client, query as q } from "faunadb".

Correct pattern: import { Client, fql } from "fauna".

2. v4 function-style queries

Claude generates: await client.query(q.Get(q.Match(q.Index("users_by_email"), email))).

Correct pattern: await client.query(fql\Users.byEmail(${email}).first()`)`.

3. String concatenation in queries

Claude generates: client.query("Users.byEmail(\"" + email + "\").first()").

Correct pattern: tagged template form fql\Users.byEmail(${email}).first()``.

4. Missing NullDocument handling

Claude generates: const user = (await fauna.query(fql\Users.byId(${id})`)).data; return user.name;`.

Correct pattern: const result = await fauna.query<...>(fql\Users.byId(${id})`); if (!result.data) throw ...; return result.data.name;`.

5. References as plain strings

Claude generates a schema with authorId: String and queries that match plain string ids.

Correct pattern: author: Ref<Users> in the FSL schema and author: Users.byId(${authorId}) when creating.

6. Split transactions

Claude generates two client.query() calls for a "deduct credits + create transfer record" operation.

Correct pattern: one fql block with both operations, using let bindings and atomic writes.

Add a common mistakes section to CLAUDE.md with these six pairs. Claude benefits from explicit before/after comparisons because it can match the pattern it would otherwise generate to the corrected form.

Permission hooks for Fauna scripts

A Fauna project accumulates scripts: schema deployments, seed data generation, role updates, data migrations. Some are read-only. Some push destructive schema changes or rewrite collections. Permission hooks gate the destructive ones.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(fauna schema diff*)",
      "Bash(fauna schema status*)",
      "Bash(npx tsx scripts/preview-query.ts*)"
    ],
    "deny": [
      "Bash(fauna schema push --target prod*)",
      "Bash(fauna schema push --no-confirm*)",
      "Bash(npx tsx scripts/seed-prod.ts*)",
      "Bash(npx tsx scripts/migrate-collection.ts*)"
    ]
  }
}

Schema diffs and read-only queries are safe. Production schema pushes, no-confirmation pushes, and migrations that rewrite collections require explicit confirmation. The deny list forces Claude to surface those operations as prompts rather than running them as part of an automated workflow. For the broader pattern, Claude Code permissions covers the deny-list approach in detail.

Building Fauna integrations that survive a schema redesign

The Fauna CLAUDE.md in this guide produces integrations where every import comes from the modern fauna package, every query uses the fql tagged template, every schema lives in version-controlled FSL files with indexes declared inline, every reference uses the typed Ref type, and every transaction stays inside a single fql block.

The underlying principle is the same as any database integration with Claude Code. Fauna without a CLAUDE.md produces code that mixes two SDK versions, ships transactions that are not actually transactional, and stores foreign keys as plain strings that lose type safety. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.

For the broader stack around Fauna, Claude Code with Drizzle covers the equivalent SQL ORM patterns for teams that compare both, and Claudify includes a Fauna-specific CLAUDE.md template with the FSL schema patterns, FQL v10 query examples, role-based auth, and all six common-mistake rules pre-configured.

Get Claudify. A complete Claude Code operating system with serverless database templates that match modern Fauna v10 conventions.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame