Building a CardDAV Server for Customer Contacts - When Next.js Gets in the Way

May 12, 2026

One of the features I wanted to add to timeli.sh is the ability for service businesses to sync their customer list directly to their phone's contacts app. If a client calls, the business owner should see "Sarah - Haircut Tuesday 3pm" rather than an unknown number. The standard protocol for this is CardDAV - the same protocol Apple Contacts, Google Contacts, and most third-party apps use to sync address books.

The implementation ended up being two distinct pieces: a connected app in the app store that organizations install and configure, and a separate lightweight Node.js server that actually speaks CardDAV. The reason those are separate - and why the CardDAV server is not just a Next.js API route - comes down to one hard limitation in Next.js that I hit immediately.


Why Next.js can't host a CardDAV server

Next.js App Router Route Handlers support a fixed set of HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. You export a function named after the method and Next.js routes requests to it.

CardDAV requires methods that are not on that list:

PROPFIND - the primary discovery method. Clients send this to list available address books and their properties. It carries an XML body describing what properties are requested and a Depth header controlling whether the server responds about just the resource or its children too.

REPORT - used for addressbook-multiget (fetch specific contacts by href) and addressbook-query (search by filter).

There is an open GitHub issue against Next.js requesting support for arbitrary HTTP methods for exactly this use case - CalDAV and CardDAV. As of writing, it remains unresolved. Even if you try to catch PROPFIND in a catch-all route handler, Next.js returns 405 Method Not Allowed before your code runs.

The solution is a plain Node.js HTTP server that handles its own routing and has no opinion about which methods are valid.


The architecture

timeli.sh monorepo
├── apps/
│ ├── web/ # Next.js public booking site
│ ├── admin/ # Next.js admin dashboard
│ ├── job-processor/ # esbuild Node.js background worker
│ └── app-external-server/ # NEW: plain http.createServer() for WebDAV/CardDAV
└── packages/
└── app-store/
└── src/apps/
└── carddav/ # the installable CardDAV app
Plain Text

The app-external-server is a lightweight HTTP server that proxies requests into the existing connected app infrastructure. It knows nothing about CardDAV specifically - it just receives a request, extracts the organization ID and app ID from the URL path, finds the right app service, and hands the Request object to processAppExternalCall. The CardDAV implementation lives entirely inside the carddav app in the app store, the same as any other connected app.


The external server

The URL pattern is /api/apps/{organizationId}/{appId}/[...slug]. Every connected app that implements processAppExternalCall is reachable through this single server:

// apps/app-external-server/src/index.ts
const server = http.createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader(
"Access-Control-Allow-Methods",
"OPTIONS, GET, POST, PUT, DELETE, PATCH, PROPFIND, REPORT, PROPPATCH, MKCOL, MOVE, COPY, LOCK, UNLOCK",
);
res.setHeader(
"Access-Control-Allow-Headers",
"Authorization, Content-Type, Depth, Prefer, If-Match, If-None-Match",
);
const parsedUrl = new URL(req.url || "", `http://${req.headers.host}`);
const pathname = parsedUrl.pathname;
const match = pathname.match(
/^\/api\/apps\/([^/]+)\/([^/]+)(?:\/(.*))?$/,
);
const organizationId = match[1];
const appId = match[2];
const slug = (match[3] || "").split("/").filter(Boolean);
const servicesContainer = ServicesContainer(organizationId);
// Convert Node.js IncomingMessage to Web API Request
const body = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
const request = new Request(requestUrl, {
method: req.method || "GET",
headers: new Headers({ /* normalized from req.headers */ }),
body: body.length > 0 ? new Uint8Array(body) : undefined,
});
const result =
await servicesContainer.connectedAppsService.processAppExternalCall(
appId,
slug,
request,
);
// Convert Web API Response back to Node.js response
res.writeHead(result.status, headersFromResponse(result));
res.end(await result.text());
});
server.listen(process.env.APP_EXTERNAL_SERVER_PORT || 5556);
TypeScript

A few things worth noting about the request/response conversion:

The server reads the full request body into a Buffer before constructing the Request object. This is necessary because Node.js streams don't map directly to the ReadableStream that the Web API Request expects, and CardDAV request bodies (especially PROPFIND and REPORT) are XML payloads that need to be read in full before parsing.

The response direction is simpler: read the Web API Response body as text and write it to the Node.js response. CardDAV responses are always XML text, so there's no binary streaming concern.

The server also normalizes the CORS headers to explicitly allow PROPFIND, REPORT, and the full set of WebDAV methods. Some CardDAV clients send a preflight OPTIONS request before the actual PROPFIND, and if the Allow header doesn't list these methods, the client will abort before attempting discovery.

The ServicesContainer(organizationId) call is the same services factory used by the Next.js apps. It initializes the database connection, loads the organization's configuration, and gives access to all the same services - including connectedAppsService which routes the call to the correct app.


The CardDAV app

The carddav app is a standard connected app in the app store. Installing it generates credentials and a URL that the organization pastes into their contacts app.

Installation and credentials

When the app is first installed, it generates a random password, encrypts it, and stores it on the connected app's data record. The username is the organization's slug - a stable, human-readable identifier:

private async installApp(appData: ConnectedAppData) {
if (appData?.data?.password) {
// Already installed, return existing status
return { status: "connected", statusText: "..." };
}
const password = generatePassword(); // crypto.randomBytes(12).toString("hex")
const encryptedPassword = encrypt(password);
const username = await this.getUsername(appData); // org.slug
const carddavUrl = generateCarddavUrl(appData.organizationId, appData._id);
await this.props.update({
account: { username, serverUrl: carddavUrl },
data: { password: encryptedPassword },
status: "connected",
});
return { status: "connected", data: { carddavUrl, username, password } };
}
TypeScript

The carddavUrl is constructed from an environment variable that points to the external server:

function generateCarddavUrl(organizationId: string, appId: string): string {
return `https://${process.env.APPS_EXTERNAL_DOMAIN}/api/apps/${organizationId}/${appId}`;
}
TypeScript

This is the URL that gets pasted into Apple Contacts, Thunderbird, or any other CardDAV client. It's unique per organization per app installation - if an organization uninstalls and reinstalls, they get a new app ID and therefore a new URL, which invalidates any previously configured connections.

Authentication

Every request through the external server goes through Basic Auth validation before the CardDAV logic runs. The credentials are checked against the stored (decrypted) password for the connected app:

function validateAuth(
request: Request,
config: CarddavConfiguration | undefined,
): boolean {
if (!config || (!config.username && !config.password)) return true;
const authHeader = request.headers.get("authorization");
const credentials = parseBasicAuth(authHeader);
if (!credentials) return false;
return (
credentials.username === config.username &&
credentials.password === config.password
);
}
TypeScript

Failed authentication returns a 401 with a WWW-Authenticate: Basic realm="CardDAV" header, which triggers the credentials prompt in most CardDAV clients.

The admin UI also provides a "regenerate password" action that replaces the stored password with a new random one. The previous password is immediately invalid, which forces all connected clients to re-authenticate.


The CardDAV protocol implementation

CardDAV is built on top of WebDAV (RFC 4918) with the address book extensions from RFC 6352. The resource tree that clients expect looks like this:

/api/apps/{organizationId}/{appId}/
principal/ - principal resource (who is authenticated)
addressbook/ - address book home
customers/ - the actual address book collection
{customerId}.vcf - individual contact (vCard)
Plain Text

Clients discover this tree by walking it from the root, following PROPFIND responses that point to deeper resources.

PROPFIND: the workhorse method

PROPFIND is used for discovery and property listing. The Depth header controls scope: Depth: 0 asks about only the requested URL, Depth: 1 asks about the URL and its immediate children.

A typical discovery sequence from Apple Contacts:

  1. PROPFIND /api/apps/{org}/{app}/ with Depth: 0 - find the principal URL

  2. PROPFIND /api/apps/{org}/{app}/principal/ - find the address book home set

  3. PROPFIND /api/apps/{org}/{app}/addressbook/ - list available address books

  4. PROPFIND /api/apps/{org}/{app}/addressbook/customers/ with Depth: 1 - list all contacts

At the customers collection level with Depth: 1, the server returns a 207 Multi-Status response with one <D:response> block per contact:

if (path === "addressbook/customers") {
const customers = await this.props.services.customersService.getCustomers(
{ limit: 1000, offset: 0 },
);
let inner = `
<D:response>
<D:href>${basePath}/addressbook/customers/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/><C:addressbook/></D:resourcetype>
<D:displayname>Customers</D:displayname>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`;
if (!depthIs0) {
for (const cust of customers.items) {
inner += `
<D:response>
<D:href>${basePath}/addressbook/customers/${cust._id}.vcf</D:href>
<D:propstat>
<D:prop>
<D:getetag>"${cust._id}"</D:getetag>
<D:getcontenttype>text/vcard; charset=utf-8</D:getcontenttype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`;
}
}
}
TypeScript

The XML namespaces matter here. WebDAV uses the DAV: namespace, conventionally prefixed as D:. CardDAV extensions use urn:ietf:params:xml:ns:carddav, prefixed as C:. Getting these wrong causes clients to silently ignore properties or fail discovery.

The ETag for each contact is the customer's _id from MongoDB. This is a content-addressable enough identifier for most clients - they use ETags to check whether a contact has changed since last sync. A more rigorous implementation would hash the vCard content, but the _id is stable enough for the read-only use case here.

REPORT: fetching contacts in bulk

After discovery, clients typically fetch contacts using one of two REPORT methods:

addressbook-multiget: the client already knows which contact URLs it wants and sends them as a list of <D:href> elements. The server responds with the vCard data for each.

addressbook-query: a filtered query. Most clients send this with no filter body, which means "give me everything".

if (isMultiget) {
// Parse <D:href> elements from the XML body
const hrefs: string[] = [];
const hrefRegex = /<\s*(?:D:)?href\s*>([^<]+)<\s*\/\s*(?:D:)?href\s*>/gi;
let m: RegExpExecArray | null;
while ((m = hrefRegex.exec(bodyText))) {
hrefs.push(m[1]);
}
for (const h of hrefs) {
const id = h.split("/").filter(Boolean).pop()?.replace(/\.vcf$/, "");
const customer = await this.props.services.customersService.getCustomer(id);
if (!customer) continue;
const vcard = customerToVCard(customer);
inner += `
<D:response>
<D:href>${basePath}/addressbook/customers/${customer._id}.vcf</D:href>
<D:propstat>
<D:prop>
<D:getetag>"${customer._id}"</D:getetag>
<C:address-data>${escapeXml(vcard)}</C:address-data>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`;
}
}
TypeScript

The <C:address-data> element inside the REPORT response carries the full vCard, XML-escaped. This is how clients can fetch many contacts in a single request rather than making individual GET calls for each.

Converting customers to vCards

The vCard format is a plain-text line-based format. Timeli.sh customers have names, emails, phones, and optional date of birth and notes. All of those map naturally to vCard properties:

function customerToVCard(customer: Customer): string {
const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"];
lines.push(`UID:${customer._id}`);
const nameParts = customer.name.split(/\s+/);
const firstName = nameParts[0] || "";
const lastName = nameParts.slice(1).join(" ") || "";
lines.push(`FN:${escapeVCardText(customer.name)}`);
lines.push(`N:${escapeVCardText(lastName)};${escapeVCardText(firstName)};;;`);
if (customer.email) lines.push(`EMAIL;TYPE=INTERNET:${customer.email}`);
if (customer.phone) lines.push(`TEL;TYPE=CELL:${customer.phone}`);
if (customer.dateOfBirth) {
const dob = new Date(customer.dateOfBirth);
lines.push(`BDAY:${dob.getFullYear()}${pad(dob.getMonth()+1)}${pad(dob.getDate())}`);
}
if (customer.note) lines.push(`NOTE:${escapeVCardText(customer.note)}`);
// Link back to the customer record in the admin
lines.push(`URL:https://${process.env.ADMIN_DOMAIN}/dashboard/customers/${customer._id}`);
lines.push(`REV:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`);
lines.push("END:VCARD");
return lines.join("\r\n"); // vCard spec requires CRLF line endings
}
TypeScript

Two details worth calling out:

The N property format is LastName;FirstName;AdditionalNames;Prefix;Suffix. Splitting a display name into first and last on whitespace is a reasonable approximation for a system that stores names as a single string. The FN (full name) property is what most apps display - the N property is used for sorting.

The URL property links to the customer record in the admin dashboard. When viewing a contact in Apple Contacts or Thunderbird, clicking the URL opens the customer directly in the admin. That's a small touch but a useful one.

The spec requires vCard lines to use \r\n (CRLF) line endings, not \n. Some clients are lenient about this; others are not. The join("\r\n") is not an accident.

The text escaping handles the characters that have special meaning in vCard: backslashes, semicolons, commas, and newlines. Unescaped semicolons in a name value, for example, would be interpreted as field separators and corrupt the parsed output.

Known phone numbers and emails

Timeli.sh tracks "known" contact information for customers - additional emails and phones discovered from past bookings that differ from their primary ones. Each of those becomes its own EMAIL or TEL line in the vCard:

if (customer.knownEmails?.length > 0) {
customer.knownEmails.forEach((email) => {
lines.push(`EMAIL;TYPE=INTERNET:${email}`);
});
}
TypeScript

This means a customer who has booked with two different email addresses shows up in the phone's contacts with both addresses, which is exactly what a business owner wants when searching their contacts.


The admin UI

From the organization's perspective, setup is one button. Clicking "Connect" generates credentials and displays the CardDAV URL, username, and password in readonly fields with copy buttons:

// apps/admin/src/app/apps/carddav/setup.tsx (simplified)
<div className="w-full p-4 bg-muted rounded-md flex flex-col gap-2">
<p className="text-sm text-muted-foreground">{t("form.info")}</p>
<CopyableField
id="carddavUrl"
label={t("form.carddavUrl.label")}
value={configuration.carddavUrl}
/>
<CopyableField
id="username"
label={t("form.username.label")}
value={configuration.username}
/>
<CopyableField
id="password"
label={t("form.password.label")}
value={configuration.password}
prefixAction={
<RegeneratePasswordButton onClick={onRegeneratePassword} />
}
/>
</div>
TypeScript

The password field has a dice icon on the left that triggers password regeneration. When clicked, it calls processRequest with { type: "reset-password" }, which generates a new password server-side, updates the stored value, and returns the plaintext new password to display. After this point, the old password is gone - existing configured clients will start getting 401 responses and prompt for the new credentials.


What I would add next

The current implementation is read-only from the CardDAV client's perspective. Contacts can't be created or updated through the CardDAV interface - it's a sync-out, not a sync-in. Adding PUT support to create or update customers from the phone's contacts app would be the natural next step, though it requires deciding how to merge incoming vCard data with the existing customer record, handle conflicts, and deal with the case where a contact update from Apple Contacts conflicts with an update from the admin dashboard.

The PROPFIND parser is also currently naive about which properties are actually being requested. The spec allows clients to send a <prop> element listing specific properties they want, and the server should only return those. The current implementation always returns the same set regardless. Most clients work fine with this since they tend to request a superset of what they need, but a strict CardDAV client would receive more data than it asked for.

blogposttimeli.shcarddavnext.js

Contact me

Email

dmytro@bondarchuk.me
© 2026 Dmytro BondarchukCreated usingTimeli.sh