# URL field (/docs/schema/fields/url)

---
title: URL field
description: "`f.url()` is a varchar whose value has to parse as a URL. The call picks the width, the width becomes the Zod cap, and the field filters with the string set plus host and protocol."
kind: reference
package: questpie
---

| Call       | Column          | Derived schema               |
| ---------- | --------------- | ---------------------------- |
| `f.url()`  | `varchar(2048)` | `z.string().url().max(2048)` |
| `f.url(n)` | `varchar(n)`    | `z.string().url().max(n)`    |

The argument is a number and nothing else. There is no options object. There is
no unbounded mode either. The constructor argument is the only thing that sets
the column width. Only `.array()` and the `.drizzle()` escape hatch replace the
column.

```ts title="src/questpie/server/collections/links.ts"
import { collection } from "#questpie/factories";

export const links = collection("links").fields(({ f }) => ({
	website: f.url().required(), // varchar(2048), NOT NULL
	webhook: f.url(500), // varchar(500)
}));
```

Without `.required()` the field selects as `string | null`. With it, `string`.

## What passes

The check hands the value to `new URL()`. Anything that parses passes. QUESTPIE
adds no scheme or hostname constraint, so the field is not limited to http and
https.

| Value                     | Result                    |
| ------------------------- | ------------------------- |
| `https://example.com/a?b` | Passes                    |
| `mailto:hi@example.com`   | Passes, any scheme parses |
| `ftp://host/file`         | Passes                    |
| `example.com`             | Rejected, no scheme       |
| `//example.com`           | Rejected, no scheme       |
| `" https://x.dev "`       | Passes, stored trimmed    |

That last row is a write, not just a check. The parser trims the value and hands
the trimmed string back. `create()` and `update()` then store what the schema
returned. Nothing else is normalized. Case, port and trailing slash are stored
exactly as you typed them.

Refine with `.zod()` to narrow the scheme. The field type carries no protocol
option.

```ts
endpoint: f.url().zod((s) => s.refine((v) => String(v).startsWith("https://"))),
```

`.zod()` receives the schema typed as `ZodType`. So the refinement's argument
arrives as `unknown`. Narrow it yourself. The refinement runs after the format
and length checks, never instead of them.

## Methods

Two methods come with the type. The base chain works here as on any field, so
`.required()`, `.default()`, `.label()` and `.localized()` all apply.

| Method    | Effect                                      |
| --------- | ------------------------------------------- |
| `.min(n)` | Adds `z.string().min(n)`, a character count |
| `.max(n)` | Adds `z.string().max(n)`, a character count |

```ts
endpoint: f.url(500).required().min(12),
```

<Callout type="warn" title="`.max()` only tightens">
	The constructor bakes its width into the schema. `.max(n)` adds a second check
	beside it. `f.url(500).max(2000)` still rejects 600 characters. The column
	stays `varchar(500)`. To widen, change the constructor argument.
</Callout>

## Filtering

URL carries `urlOps`, the fourteen string operators plus three of its own.

| Operator               | Operand             | Matches                                     |
| ---------------------- | ------------------- | ------------------------------------------- |
| `eq` / `ne`            | `string`            | Equal, not equal                            |
| `not`                  | `string`, or `null` | Not equal, or `IS NOT NULL` when given null |
| `in` / `notIn`         | `string[]`          | In the list, not in the list                |
| `like` / `notLike`     | `string`            | `LIKE`, case-sensitive, you write the `%`   |
| `ilike` / `notIlike`   | `string`            | The same, case-insensitive                  |
| `contains`             | `string`            | Substring, the `%` are added for you        |
| `startsWith`           | `string`            | Prefix                                      |
| `endsWith`             | `string`            | Suffix                                      |
| `isNull` / `isNotNull` | `boolean`           | Pass `false` and each inverts               |

The three additions:

| Operator   | Operand    | Compiles to                                      |
| ---------- | ---------- | ------------------------------------------------ |
| `host`     | `string`   | `col ILIKE '%://value%'`                         |
| `hostIn`   | `string[]` | The same, one arm per value, joined with `OR`    |
| `protocol` | `string`   | `col LIKE 'value://%'`, case-sensitive, anchored |

```ts
const { docs } = await app.collections.links.find({
	where: { website: { host: "questpie.com" } },
});
```

<Callout
	type="warn"
	title="`host` and `protocol` are patterns, not a parsed URL"
>
	`host` looks for `://` plus your value anywhere in the string. So
	`example.com` also matches `https://example.com.evil.net`. It matches any URL
	carrying that text in its query too. `protocol` needs the `//`, so `mailto`
	never matches. An empty `hostIn` compiles to `FALSE`.
</Callout>

## In the admin

The form control is `TextField`. The list cell is `TextCell`. It truncates the
text and puts the full value in a `title` attribute. Neither renders a link.

What crosses to the browser:

- The input is `type="text"`. The primitive does support `type="url"` and the
  URL touch keyboard that goes with it. The registration asks for neither.
- `maxLength` becomes the input's `maxLength` attribute. Typing stops there.
  `.max(n)` overwrites that number, even when it sits above the constructor
  width.
- The admin rebuilds its own schema for the form. It uses `z.url()` and
  `maxLength` only. It never reads `minLength`, so `.min(n)` is a server-side
  check. [Text](/docs/schema/fields/text) takes the generic path instead, which
  applies both bounds.

Configure the rest with `.admin()`. `@questpie/admin` registers that field
extension.

| Key                    | Type                | Effect                  |
| ---------------------- | ------------------- | ----------------------- |
| `placeholder`          | `string`            | The input's placeholder |
| `showProtocolDropdown` | `boolean`           | Nothing. Declared only  |
| `defaultProtocol`      | `"http" \| "https"` | Nothing. Declared only  |

The last two type-check and reach the component as props. No component under
`packages/` reads either one. The shared `.admin()` keys behave as they do on
any field. That covers `hidden`, `readOnly`, `group`, `colspan` and the rest.

## Lists

`.array()` replaces the column with `jsonb`. It also swaps `urlOps` for the
multi-value set. So `host` and `protocol` are gone from the filter. Each element
is still parsed as a URL on write.

```ts
mirrors: f.url().array().maxItems(5),
```

The admin's item-type allowlist has no `url` in it. Items get a plain text
input. The form runs no per-item check. The write catches a bad element.

## Related

[Text](/docs/schema/fields/text) is the plain string. It owns the `stringOps`
set that URL extends.

[Email](/docs/schema/fields/email) is the other format-checked varchar. It has
`domain` and `domainIn` in place of `host`, `hostIn` and `protocol`.

[Arrays](/docs/schema/fields/array) covers what `.array()` does to reads, writes
and filters.

[Uploads](/docs/schema/fields/upload) is for a file you store rather than a link
you point at.

[Validation](/docs/schema/validation) covers the derived schema and `.zod()`.
