Skip to content

Commit f9964b0

Browse files
ping-maxwellcubic-dev-ai[bot]Bekacru
authored
feat: Improved API error page (#5272)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Bereket Engida <Bekacru@gmail.com>
1 parent 67aa5a1 commit f9964b0

17 files changed

+908
-105
lines changed

docs/app/docs/[[...slug]]/page.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,14 @@ export default async function Page({
3535
params: Promise<{ slug?: string[] }>;
3636
}) {
3737
const { slug } = await params;
38-
const page = source.getPage(slug);
38+
let page = source.getPage(slug);
3939

4040
if (!page) {
41-
notFound();
41+
if (slug?.[0] === "errors") {
42+
page = source.getPage(["errors", "unknown"])!;
43+
} else {
44+
return notFound();
45+
}
4246
}
4347

4448
const MDX = page.data.body;
@@ -75,7 +79,7 @@ export default async function Page({
7579
return (
7680
<CodeBlockTabs
7781
{...props}
78-
className="p-0 border-0 rounded-lg bg-fd-secondary"
82+
className="p-0 rounded-lg border-0 bg-fd-secondary"
7983
>
8084
<div {...props}>{props.children}</div>
8185
</CodeBlockTabs>
@@ -173,8 +177,14 @@ export async function generateMetadata({
173177
params: Promise<{ slug?: string[] }>;
174178
}) {
175179
const { slug } = await params;
176-
const page = source.getPage(slug);
177-
if (page == null) notFound();
180+
let page = source.getPage(slug);
181+
if (page == null) {
182+
if (slug?.[0] === "errors") {
183+
page = source.getPage(["errors", "unknown"])!;
184+
} else {
185+
return notFound();
186+
}
187+
}
178188
const baseUrl = process.env.NEXT_PUBLIC_URL || process.env.VERCEL_URL;
179189
const url = new URL(`${baseUrl}/api/og`);
180190
const { title, description } = page.data;

docs/components/floating-ai-search.tsx

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function SearchAIInput(props: ComponentProps<"form"> & { isMobile?: boolean }) {
101101
return (
102102
<div
103103
className={cn(
104-
"flex flex-col relative bg-fd-background m-[1px] border border-fd-border rounded-lg shadow-2xl shadow-fd-background",
104+
"flex relative flex-col rounded-lg border shadow-2xl bg-fd-background m-[1px] border-fd-border shadow-fd-background",
105105
isLoading ? "opacity-50" : "",
106106
)}
107107
>
@@ -132,12 +132,12 @@ function SearchAIInput(props: ComponentProps<"form"> & { isMobile?: boolean }) {
132132
className={cn(
133133
buttonVariants({
134134
color: "secondary",
135-
className: "transition-all rounded-full mt-2 gap-2",
135+
className: "gap-2 mt-2 rounded-full transition-all",
136136
}),
137137
)}
138138
onClick={stop}
139139
>
140-
<Loader2 className="size-4 animate-spin text-fd-muted-foreground" />
140+
<Loader2 className="animate-spin size-4 text-fd-muted-foreground" />
141141
</button>
142142
) : (
143143
<button
@@ -146,7 +146,7 @@ function SearchAIInput(props: ComponentProps<"form"> & { isMobile?: boolean }) {
146146
className={cn(
147147
buttonVariants({
148148
color: "secondary",
149-
className: "transition-all rounded-full mt-2",
149+
className: "mt-2 rounded-full transition-all",
150150
}),
151151
)}
152152
disabled={input.length === 0}
@@ -158,7 +158,7 @@ function SearchAIInput(props: ComponentProps<"form"> & { isMobile?: boolean }) {
158158

159159
{showSuggestions && (
160160
<div className={cn("mt-3", props.isMobile ? "px-3" : "px-4")}>
161-
<p className="text-xs font-medium text-fd-muted-foreground mb-2">
161+
<p className="mb-2 text-xs font-medium text-fd-muted-foreground">
162162
Try asking:
163163
</p>
164164
<div
@@ -199,7 +199,7 @@ function SearchAIInput(props: ComponentProps<"form"> & { isMobile?: boolean }) {
199199
<Popover>
200200
<PopoverTrigger asChild>
201201
<button
202-
className="sm:hidden hover:bg-fd-accent/50 rounded transition-colors"
202+
className="rounded transition-colors sm:hidden hover:bg-fd-accent/50"
203203
aria-label="Show information"
204204
>
205205
<InfoIcon className="size-3.5" />
@@ -208,17 +208,17 @@ function SearchAIInput(props: ComponentProps<"form"> & { isMobile?: boolean }) {
208208
<PopoverContent
209209
side="top"
210210
align="end"
211-
className="w-auto max-w-44 p-2 text-xs text-pretty"
211+
className="p-2 w-auto text-xs max-w-44 text-pretty"
212212
>
213213
AI can be inaccurate, please verify the information.
214214
</PopoverContent>
215215
</Popover>
216216
</div>
217217
)}
218218
{!showSuggestions && (
219-
<div className="border-t px-4 text-xs text-fd-muted-foreground cursor-pointer bg-fd-accent/40 flex items-center gap-1 mt-2 py-2">
219+
<div className="flex gap-1 items-center px-4 py-2 mt-2 text-xs border-t cursor-pointer text-fd-muted-foreground bg-fd-accent/40">
220220
<div
221-
className="flex items-center gap-1 empty:hidden hover:text-fd-foreground transition-all duration-200 aria-disabled:opacity-50 aria-disabled:cursor-not-allowed"
221+
className="flex gap-1 items-center transition-all duration-200 empty:hidden hover:text-fd-foreground aria-disabled:opacity-50 aria-disabled:cursor-not-allowed"
222222
role="button"
223223
aria-disabled={isLoading}
224224
tabIndex={0}
@@ -331,11 +331,11 @@ function Input(props: ComponentProps<"textarea">) {
331331
id="nd-ai-input"
332332
{...props}
333333
className={cn(
334-
"resize-none bg-transparent placeholder:text-fd-muted-foreground focus-visible:outline-none",
334+
"bg-transparent resize-none placeholder:text-fd-muted-foreground focus-visible:outline-none",
335335
shared,
336336
)}
337337
/>
338-
<div ref={ref} className={cn(shared, "break-all invisible")}>
338+
<div ref={ref} className={cn(shared, "invisible break-all")}>
339339
{`${props.value?.toString() ?? ""}\n`}
340340
</div>
341341
</div>
@@ -353,8 +353,8 @@ function ThinkingIndicator() {
353353
<p className="mb-1 text-sm font-medium text-fd-muted-foreground">
354354
BA bot
355355
</p>
356-
<div className="flex items-end gap-1 text-sm text-fd-muted-foreground">
357-
<div className="flex items-center gap-1 opacity-70">
356+
<div className="flex gap-1 items-end text-sm text-fd-muted-foreground">
357+
<div className="flex gap-1 items-center opacity-70">
358358
<span className="inline-block size-1 bg-fd-primary rounded-full animate-bounce [animation-delay:0ms]" />
359359
<span className="inline-block size-1 opacity-80 bg-fd-primary rounded-full animate-bounce [animation-delay:150ms]" />
360360
<span className="inline-block size-1 bg-fd-primary rounded-full animate-bounce [animation-delay:300ms]" />
@@ -413,11 +413,11 @@ function Message({
413413
>
414414
{roleName[message.role] ?? "unknown"}
415415
</p>
416-
<div className="prose text-sm">
416+
<div className="text-sm prose">
417417
<Markdown text={markdown} />
418418
</div>
419419
{links && links.length > 0 && (
420-
<div className="mt-3 flex flex-col gap-2">
420+
<div className="flex flex-col gap-2 mt-3">
421421
<p className="text-xs font-medium text-fd-muted-foreground">
422422
References:
423423
</p>
@@ -426,7 +426,7 @@ function Message({
426426
<Link
427427
key={i}
428428
href={item.url}
429-
className="flex items-center gap-2 text-xs rounded-lg border p-2 hover:bg-fd-accent hover:text-fd-accent-foreground transition-colors"
429+
className="flex gap-2 items-center p-2 text-xs rounded-lg border transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground"
430430
target="_blank"
431431
rel="noopener noreferrer"
432432
>
@@ -561,11 +561,11 @@ export function AISearchTrigger() {
561561
>
562562
<div
563563
className={cn(
564-
"sticky top-0 flex gap-2 items-center py-2",
564+
"flex sticky top-0 gap-2 items-center py-2",
565565
isMobile ? "w-full" : "w-[min(800px,90vw)]",
566566
)}
567567
>
568-
<div className="flex justify-end w-full items-center">
568+
<div className="flex justify-end items-center w-full">
569569
<button
570570
aria-label="Close"
571571
tabIndex={-1}
@@ -587,7 +587,7 @@ export function AISearchTrigger() {
587587
className={cn(
588588
"overscroll-contain",
589589
isMobile
590-
? "pt-6 pb-28 px-2 w-full"
590+
? "px-2 pt-6 pb-28 w-full"
591591
: "py-10 pr-2 w-[min(800px,90vw)]",
592592
)}
593593
style={{
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
title: Account already linked to different user
3+
description: The account is already linked to a different user.
4+
---
5+
6+
## What is it?
7+
8+
This error occurs during the OAuth flow when attempting to link an OAuth provider account
9+
to the currently authenticated user, but that exact provider account is already linked to
10+
another user in your project. To prevent account takeover, Better Auth blocks the link and
11+
throws this error.
12+
13+
This situation is only possible through the OAuth flow (e.g., Google, GitHub, etc.). It is
14+
not triggered by email/password flows on their own.
15+
16+
## How to resolve
17+
18+
### Typical resolutions
19+
20+
* Log in as the user who already has the provider linked, unlink the provider from that account,
21+
then link it to the intended account.
22+
* If both accounts belong to the same person and you want a single user, merge the accounts: choose
23+
a primary user, move sessions and linked accounts from the secondary user to the primary, then
24+
deactivate or delete the secondary.
25+
26+
### Common Causes
27+
28+
* You previously signed in or signed up using this provider on a different user in the same project.
29+
* You have two local users (e.g., created via email/password or magic link) and you linked the provider
30+
to one of them; now you are trying to link the same provider to the other.
31+
* Test/preview environments share the same OAuth provider configuration and database; the provider account
32+
is already linked to a different user record.
33+
* Data migration or manual database edits left a stale link pointing to the wrong user.
34+
* You rely on email matching to decide linking, but the actual unique key is the provider account identifier
35+
(e.g., `providerId` + `accountId`). If that mapping exists for another user, linking will be blocked.
36+
37+
### Safer patterns and prevention
38+
39+
* Avoid automatically linking a provider to whichever user is currently signed in unless you explicitly
40+
confirm ownership with the user.
41+
* If you provide a 'Connect account' UI, clearly communicate which user will receive the link and what to do
42+
if the provider is already linked elsewhere.
43+
* Consider disabling linking for providers you only want to use for sign-in, to avoid accidental cross-linking.
44+
45+
### Debug locally
46+
47+
* Inspect your `account` database table. You should see rows keyed by
48+
`providerId` (e.g., 'google') and `accountId` (e.g., OIDC `sub`), pointing to a `userId`.
49+
* Identify which user currently owns the provider link and decide whether to unlink, merge, or keep as-is.
50+
* Verify your app is connected to the expected database and environment (dev/staging/prod) to avoid confusion
51+
due to shared credentials or misconfigured environment variables.
52+
53+
### Provider considerations
54+
55+
* Ensure you request stable user identifiers from the provider (e.g., OIDC `openid` scope) so `accountId`
56+
remains consistent across sessions.
57+
* If you changed provider projects/tenants, identifiers may differ; confirm you are linking the correct provider
58+
credentials for the environment.
59+
60+
<Callout type="info">
61+
This error is a security safeguard. It prevents an OAuth identity that already belongs to one user
62+
from being attached to another user without explicit action. If a legitimate merge is intended, perform
63+
a controlled merge or unlink-then-link flow rather than bypassing the check.
64+
</Callout>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
title: Email doesn't match
3+
description: The email doesn't match the email of the account.
4+
---
5+
6+
## What is it?
7+
8+
This error appears only during OAuth account linking. It happens when a signed-in user tries to
9+
link an OAuth provider account, but the email returned by the provider does not match the email
10+
on the currently authenticated user (or the email you expect for that user). To prevent
11+
accidental cross-account linking or account takeover, Better Auth blocks the link when emails do
12+
not align.
13+
14+
This does not occur during normal OAuth sign-in; it is specific to the linking flow.
15+
16+
## Common Causes
17+
18+
* The user is logged into the provider with a different email (e.g., work vs personal).
19+
* The provider returns an unverified or secondary email that differs from the app account email.
20+
* Email normalization differences (case sensitivity, dots/aliases on Gmail) cause a mismatch.
21+
* The user's email changed in your app or at the provider since the original account was created.
22+
23+
## How to resolve
24+
25+
### Ask the user to align identities
26+
27+
* Have the user switch to the correct provider account that uses the same email as their app account.
28+
* Alternatively, update the app account email to the intended email (if your product allows it) and retry linking.
29+
30+
### Debug locally
31+
32+
* Log the current user's email in your app and the email returned by the provider profile.
33+
* Inspect whether the provider email is verified/primary and whether any normalization is applied.
34+
* Confirm which provider credentials (dev/staging/prod) are in use and that the returned identity is the expected one.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
title: Email not found
3+
description: The provider did not return an email address.
4+
---
5+
6+
## What is it?
7+
8+
This error occurs during the OAuth flow when the provider does not return an email address for the user.
9+
Better Auth uses the email from the provider to identify or create a user account. If the provider omits
10+
the email (or returns it as empty/undefined), we cannot proceed and the request is rejected.
11+
12+
This error is only possible through OAuth providers. It will not occur in non-OAuth flows.
13+
14+
## Common Causes
15+
16+
* Missing or insufficient scopes in the provider configuration (e.g., not requesting `email`).
17+
* The user's email is private or not exposed by default (e.g., GitHub private email).
18+
* The provider returns email only via a separate endpoint and the scope/API call to fetch it was not enabled
19+
(e.g., GitHub `user:email`).
20+
* Provider project or tenant misconfiguration (consent screen, admin consent, restricted claims/attributes).
21+
* Using different credentials between environments (preview/staging/prod) that do not request the same scopes.
22+
23+
## How to resolve
24+
25+
### Request the correct scopes
26+
27+
* Ensure your provider configuration requests the email-related scopes.
28+
29+
### Verify provider app/dashboard settings
30+
31+
* In the provider's dashboard, confirm the app has permission to request email and the consent screen allows it.
32+
33+
### Debug locally
34+
35+
* Inspect the outgoing authorize request to confirm the scopes include `email` where required.
36+
* Inspect the callback payload (query, `id_token` claims, userinfo response) to see if an email claim exists.
37+
* Log the provider profile object received by your callback handler to verify whether `email` is present.
38+
* Check which environment's provider credentials are in use and whether scopes differ across environments.

docs/content/docs/errors/index.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
title: Errors
3+
description: Errors that can occur in Better Auth.
4+
---
5+
6+
This section contains all the errors that cause you to be redirected to the `/api/auth/error` page.
7+
8+
## List of errors
9+
10+
- [Invalid callback request](/docs/errors/invalid_callback_request)
11+
- [State not found](/docs/errors/state_not_found)
12+
- [Account already linked to different user](/docs/errors/account_already_linked_to_different_user)
13+
- [Email doesn't match](/docs/errors/email_doesn't_match)
14+
- [Email not found](/docs/errors/email_not_found)
15+
- [No callback URL](/docs/errors/no_callback_url)
16+
- [No code](/docs/errors/no_code)
17+
- [OAuth provider not found](/docs/errors/oauth_provider_not_found)
18+
- [Unable to link account](/docs/errors/unable_to_link_account)
19+
- [Unable to get user info](/docs/errors/unable_to_get_user_info)
20+
- [State mismatch](/docs/errors/state_mismatch)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
title: Invalid callback request
3+
description: The callback request is invalid.
4+
---
5+
6+
## What is it?
7+
8+
This error is thrown during the OAuth callback when the incoming request cannot be parsed or is missing
9+
required fields.
10+
11+
## Common Causes
12+
13+
* Query or body parameters were stripped by a reverse proxy, CDN, or framework rewrite.
14+
* Double-encoding or improper URL encoding of parameters causes parsing to fail.
15+
* Callback URL mismatch at the provider triggers an intermediate redirect that drops parameters.
16+
* Middleware or route grouping sends the request to a different handler than intended.
17+
* Very long URLs get truncated by an intermediary (rare but possible with some proxies).
18+
19+
## How to resolve
20+
21+
### Verify callback method and parameters
22+
23+
* Ensure your provider is configured to use the method your route expects (commonly GET with query parameters for Authorization Code flow).
24+
* Confirm the callback includes required parameters (e.g., `code` and `state` for standard OAuth flows).
25+
26+
### Preserve query/body through infrastructure
27+
28+
* Check that reverse proxies (Vercel, Cloudflare, Nginx) and app rewrites forward the full query string and request body intact.
29+
* If middleware intercepts or rewrites the callback, make sure it forwards all parameters without modification.
30+
31+
### Debug locally
32+
33+
* In DevTools → Network, inspect the callback request and verify parameters are present and well-formed.
34+
* Compare dev/staging/prod credentials to ensure there are no environment differences causing different flows or endpoints.
35+
36+
### Edge cases to consider
37+
38+
* Mobile/WebView or deep-link flows can drop query parameters during handoff.
39+
* Some providers can return parameters in fragments; your server will not receive fragments—ensure the provider uses query/body for server-side callbacks.
40+
* Multiple redirects (including HTTP → HTTPS) can lose parameters if not configured correctly.
41+
42+
<Callout type="info">
43+
Callback parameters are normally handled automatically by Better Auth. If this error appears, it often
44+
indicates manual access to the `/api/auth/callback` route, a proxy/redirect that stripped parameters,
45+
or an integration mismatch. Double-check provider settings and infrastructure rewrites to ensure the
46+
full request reaches the callback unchanged.
47+
</Callout>

0 commit comments

Comments
 (0)