Building a CardDAV Server for Customer Contacts - When Next.js Gets in the Way
May 12, 2026One 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 appThe 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.tsconst 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);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 } };}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}`;}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 );}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)Clients discover this tree by walking it from the root, following PROPFIND responses that point to deeper resources.
PROPFIND: the workhorse method
PROPFINDis used for discovery and property listing. TheDepthheader controls scope:Depth: 0asks about only the requested URL,Depth: 1asks about the URL and its immediate children.
A typical discovery sequence from Apple Contacts:
PROPFIND /api/apps/{org}/{app}/withDepth: 0- find the principal URLPROPFIND /api/apps/{org}/{app}/principal/- find the address book home setPROPFIND /api/apps/{org}/{app}/addressbook/- list available address booksPROPFIND /api/apps/{org}/{app}/addressbook/customers/withDepth: 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>`; } }}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>`; }}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}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}`); });}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>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.