docs: add Trellis planning and project specs

This commit is contained in:
2026-07-01 06:27:55 -07:00
parent 7f227c5f2a
commit aafd9caaac
349 changed files with 55801 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
# Common Issues and Solutions
> Documented pitfalls discovered while building production Next.js fullstack applications.
> These issues apply to any project using Next.js with PostgreSQL, Drizzle ORM, Tailwind CSS, and i18n.
## Severity Levels
| Level | Description |
| -------- | ---------------------------------------------------- |
| Critical | Build fails or data corruption |
| Warning | Degraded experience, workaround exists |
| Info | Minor visual issue, easy to fix once identified |
---
## Issue Index
| Issue | Category | Severity |
| ---------------------------------------------------------------- | ---------------- | -------- |
| [postgres-json-jsonb.md](./postgres-json-jsonb.md) | Database/ORM | Critical |
| [sentry-nextintl-conflict.md](./sentry-nextintl-conflict.md) | Plugin Conflicts | Critical |
| [turbopack-webpack-flexbox.md](./turbopack-webpack-flexbox.md) | Build System | Warning |
| [webkit-tap-highlight.md](./webkit-tap-highlight.md) | Mobile/CSS | Info |
---
## How to Contribute
Found a new pitfall? Add it to this directory:
1. Create a new `.md` file with a descriptive kebab-case name
2. Follow the existing format: Problem, Root Cause, Solution, Key Takeaways
3. Update this index table with the correct category and severity
4. Include reproducible code examples whenever possible

View File

@@ -0,0 +1,192 @@
# PostgreSQL JSON vs JSONB Type Issues with Drizzle ORM
## Problem
Database queries using PostgreSQL's `jsonb_*` functions fail with type errors, even though the column appears to store JSON data correctly.
**Error message:**
```
function jsonb_array_elements(json) does not exist
HINT: No function matches the given name and argument types.
You might need to add explicit type casts.
```
**Additional symptoms:**
- Raw SQL queries fail to find columns with camelCase names
- JSON aggregation functions return unexpected results
## Root Cause
### Issue 1: Drizzle's `json()` Maps to PostgreSQL `json`, Not `jsonb`
When defining a JSON column in Drizzle ORM:
```typescript
// Drizzle schema definition
export const orders = pgTable("orders", {
id: text("id").primaryKey(),
metadata: json("metadata"), // Creates PostgreSQL 'json' type, NOT 'jsonb'
});
```
PostgreSQL has two JSON types with different characteristics:
| Feature | `json` | `jsonb` |
|---------|--------|---------|
| Storage | Text (preserves whitespace, key order) | Binary (normalized) |
| Functions | `json_*` functions only | `jsonb_*` functions only |
| Indexing | Limited | GIN indexes supported |
| Performance | Slower for operations | Faster for operations |
The `jsonb_*` functions (like `jsonb_array_elements`, `jsonb_extract_path`) **only work with `jsonb` type**.
### Issue 2: Column Name Case Sensitivity
PostgreSQL treats unquoted identifiers as lowercase. If your column uses camelCase:
```sql
-- This fails (looks for column named 'metadata' in lowercase)
SELECT metadata->>'userId' FROM orders;
-- Column is actually named "metaData" with exact case
SELECT "metaData"->>'userId' FROM orders;
```
## Solution
### Solution 1: Use Type Cast for jsonb Functions
Add `::jsonb` type cast before using jsonb functions:
```typescript
// Before (fails)
const result = await db.execute(sql`
SELECT jsonb_array_elements(items) as item
FROM orders
WHERE id = ${orderId}
`);
// After (works)
const result = await db.execute(sql`
SELECT jsonb_array_elements(items::jsonb) as item
FROM orders
WHERE id = ${orderId}
`);
```
### Solution 2: Define Column as `jsonb` in Schema
If you need jsonb functionality frequently, define the column as jsonb:
```typescript
import { pgTable, text, jsonb } from "drizzle-orm/pg-core";
export const orders = pgTable("orders", {
id: text("id").primaryKey(),
metadata: jsonb("metadata"), // Now uses PostgreSQL 'jsonb' type
});
```
**Note:** This requires a migration if the column already exists.
### Solution 3: Quote camelCase Column Names in Raw SQL
Always use double quotes for camelCase column names:
```typescript
// Before (fails - column not found)
const result = await db.execute(sql`
SELECT "userId", createdAt
FROM orders
`);
// After (works)
const result = await db.execute(sql`
SELECT "userId", "createdAt"
FROM orders
`);
```
### Complete Example: Querying JSON Array Data
```typescript
import { sql } from "drizzle-orm";
// Table with json column storing an array of items
// items: [{ "productId": "123", "quantity": 2 }, ...]
// Query to find orders containing a specific product
async function findOrdersWithProduct(productId: string) {
const result = await db.execute(sql`
SELECT
o.id,
o."createdAt",
item->>'productId' as "productId",
(item->>'quantity')::int as quantity
FROM orders o,
jsonb_array_elements(o.items::jsonb) as item
WHERE item->>'productId' = ${productId}
`);
return result.rows;
}
// Query to aggregate JSON array data
async function getOrderItemStats(orderId: string) {
const result = await db.execute(sql`
SELECT
COUNT(*) as "itemCount",
SUM((item->>'quantity')::int) as "totalQuantity"
FROM orders o,
jsonb_array_elements(o.items::jsonb) as item
WHERE o.id = ${orderId}
`);
return result.rows[0];
}
```
### Best Practice: Create a Helper for JSON Queries
```typescript
// utils/db-helpers.ts
import { sql, SQL } from "drizzle-orm";
/**
* Wraps a column reference with ::jsonb cast for use with jsonb functions
*/
export function asJsonb(column: SQL | string): SQL {
if (typeof column === "string") {
return sql.raw(`"${column}"::jsonb`);
}
return sql`${column}::jsonb`;
}
// Usage
const result = await db.execute(sql`
SELECT jsonb_array_elements(${asJsonb("items")}) as item
FROM orders
`);
```
## Key Takeaways
1. **Know the difference between `json` and `jsonb`**
- `json`: Text storage, use `json_*` functions
- `jsonb`: Binary storage, use `jsonb_*` functions, better performance
2. **Drizzle's `json()` creates PostgreSQL `json` type** - use `jsonb()` if you need jsonb functionality
3. **Always add `::jsonb` cast** when using jsonb functions with json columns
4. **Quote camelCase identifiers** in raw SQL queries with double quotes
5. **Prefer Drizzle's query builder** over raw SQL when possible to avoid these issues
6. **Test raw SQL queries** directly in a PostgreSQL client before using in code
## Related Resources
- [PostgreSQL JSON Types Documentation](https://www.postgresql.org/docs/current/datatype-json.html)
- [PostgreSQL JSON Functions](https://www.postgresql.org/docs/current/functions-json.html)
- [Drizzle ORM PostgreSQL Column Types](https://orm.drizzle.team/docs/column-types/pg)

View File

@@ -0,0 +1,223 @@
# Sentry and next-intl Build Configuration Conflict
## Problem
Production builds fail with the error:
```
Error: Couldn't find next-intl config file
```
or
```
Error: Failed to collect page data for /[locale]/page
```
The build works fine in development mode but fails during `next build`.
## Root Cause
The `withSentryConfig` wrapper in `next.config.js` interferes with other Next.js plugins, particularly `next-intl`'s plugin (`createNextIntlPlugin`).
### Technical Details
When you chain multiple config wrappers:
```javascript
// next.config.js - Problematic configuration
import { withSentryConfig } from "@sentry/nextjs";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig = {
// your config
};
// This chaining causes conflicts
export default withSentryConfig(withNextIntl(nextConfig), {
// Sentry options
});
```
The issue occurs because:
1. **Plugin execution order matters** - Sentry's wrapper modifies the webpack configuration in ways that can break other plugins' assumptions
2. **Build-time vs runtime** - Some plugins expect to run at specific build phases
3. **Config mutation** - Wrappers may mutate the config object in incompatible ways
Specifically, `withSentryConfig`:
- Modifies webpack configuration extensively
- Adds custom loaders and plugins
- May interfere with `next-intl`'s message loading mechanism
## Solution
### Solution 1: Remove withSentryConfig Wrapper (Recommended)
Sentry's runtime features still work via `instrumentation.ts` without the config wrapper:
```javascript
// next.config.js - Fixed configuration
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig = {
// your config
};
// Only use next-intl wrapper, no Sentry wrapper
export default withNextIntl(nextConfig);
```
**Why Sentry still works:**
The `withSentryConfig` wrapper is primarily for:
- Source map uploading
- Build-time instrumentation
- Release management
However, Sentry's core error tracking works through `instrumentation.ts`:
```typescript
// instrumentation.ts
import * as Sentry from "@sentry/nextjs";
export function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
// ... other options
});
}
if (process.env.NEXT_RUNTIME === "edge") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
});
}
}
```
### Solution 2: Use Sentry Without Source Maps
If you need some Sentry build features but want to avoid conflicts:
```javascript
// next.config.js
import { withSentryConfig } from "@sentry/nextjs";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig = {
// your config
};
// Apply next-intl first
const configWithIntl = withNextIntl(nextConfig);
// Conditionally apply Sentry only if not causing issues
const finalConfig = process.env.SKIP_SENTRY_BUILD
? configWithIntl
: withSentryConfig(configWithIntl, {
silent: true,
disableSourceMapUpload: true, // Disable problematic feature
});
export default finalConfig;
```
### Solution 3: Alternative Plugin Order
Sometimes reversing the wrapper order helps:
```javascript
// Try applying Sentry first, then next-intl
const configWithSentry = withSentryConfig(nextConfig, sentryOptions);
export default withNextIntl(configWithSentry);
```
**Note:** This may or may not work depending on your specific versions.
### Solution 4: Separate Sentry Configuration
Use Sentry CLI for source map upload instead of the webpack plugin:
```bash
# In your CI/CD pipeline after build
npx @sentry/cli sourcemaps upload ./next/static --org your-org --project your-project
```
```javascript
// next.config.js - Clean configuration
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig = {
productionBrowserSourceMaps: true, // Enable source maps for Sentry CLI
};
export default withNextIntl(nextConfig);
```
## Verification Steps
After applying the fix:
1. **Clean build artifacts:**
```bash
rm -rf .next node_modules/.cache
```
2. **Test production build:**
```bash
pnpm build
```
3. **Verify Sentry works in production:**
```bash
pnpm start
# Trigger a test error and check Sentry dashboard
```
4. **Test i18n routing:**
```bash
# Visit different locale routes
curl http://localhost:3000/en/page
curl http://localhost:3000/de/page
```
## Key Takeaways
1. **Plugin wrappers can conflict** - Be cautious when combining multiple Next.js config wrappers
2. **Sentry works without withSentryConfig** - Core error tracking functions via `instrumentation.ts`
3. **Order matters** - Try different wrapper orders if you must use multiple plugins
4. **Source maps are optional** - You can still get stack traces without source map upload
5. **Test builds locally** - Always run `pnpm build` before deploying
6. **Keep dependencies updated** - Plugin compatibility issues are often fixed in newer versions
## Version Information
This issue was observed with:
- Next.js 14.x / 15.x
- @sentry/nextjs 7.x / 8.x
- next-intl 3.x
Check the respective changelogs for compatibility updates.
## Related Resources
- [Sentry Next.js SDK Documentation](https://docs.sentry.io/platforms/javascript/guides/nextjs/)
- [next-intl Plugin Documentation](https://next-intl-docs.vercel.app/docs/getting-started/app-router)
- [Next.js Configuration](https://nextjs.org/docs/app/api-reference/next-config-js)

View File

@@ -0,0 +1,141 @@
# Turbopack vs Webpack Flexbox Layout Differences
## Problem
Layout works correctly in development mode (Turbopack) but breaks in production (Webpack). Specifically, flex containers and their children behave differently between the two bundlers.
**Symptoms:**
- Components have correct height in dev mode but collapse or overflow in production
- Scrollable areas work in dev but fail in prod
- Nested flex layouts display differently between environments
## Root Cause
Turbopack (Next.js dev mode default) and Webpack (production build) have subtle differences in how they process CSS, particularly regarding flexbox behavior:
1. **Turbopack is stricter** about explicit flexbox properties
2. **Webpack may auto-infer** certain flex child behaviors that Turbopack does not
3. The difference lies in how CSS is compiled and applied, not in the CSS specification itself
### Technical Details
When a flex container has `flex-direction: column` and children that need to fill available space, the behavior depends on:
- The `align-items` property (defaults to `stretch` but may not be consistently applied)
- Whether children have explicit `height` or `flex` properties
- The interaction between nested flex containers
**Example of problematic layout:**
```tsx
// Parent component
<div className="flex flex-col h-screen">
<Header /> {/* Fixed height */}
<main className="flex-1 flex"> {/* Should fill remaining space */}
<Sidebar />
<Content /> {/* Should scroll internally */}
</main>
</div>
```
In Turbopack, the `main` element might not properly pass its height to children without explicit `items-stretch`.
## Solution
### 1. Explicitly Set `items-stretch` on Flex Containers
Add `items-stretch` to main flex containers that need children to fill available space:
```tsx
// Before (inconsistent between Turbopack/Webpack)
<div className="flex flex-col h-screen">
<main className="flex-1 flex">
{/* children */}
</main>
</div>
// After (consistent behavior)
<div className="flex flex-col h-screen items-stretch">
<main className="flex-1 flex items-stretch">
{/* children */}
</main>
</div>
```
### 2. Apply Parent/Child Responsibility Separation
Follow a clear pattern for layout responsibilities:
**Parent's Responsibility:**
- Define the flex container (`flex`, `flex-col`, `flex-row`)
- Set alignment (`items-stretch`, `justify-between`)
- Control overall dimensions (`h-screen`, `w-full`)
**Child's Responsibility:**
- Define its own flex behavior (`flex-1`, `flex-shrink-0`)
- Handle internal overflow (`overflow-auto`, `overflow-hidden`)
- Set min/max constraints (`min-h-0`, `max-w-full`)
### 3. Use `min-h-0` for Scrollable Flex Children
When a flex child needs internal scrolling:
```tsx
<div className="flex flex-col h-full items-stretch">
<div className="flex-shrink-0">Fixed Header</div>
<div className="flex-1 min-h-0 overflow-auto">
{/* Scrollable content */}
</div>
</div>
```
The `min-h-0` is crucial because flex items default to `min-height: auto`, which can prevent overflow from working correctly.
### Complete Example
```tsx
// App layout with consistent dev/prod behavior
function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-col h-screen items-stretch">
{/* Fixed navigation */}
<nav className="flex-shrink-0 h-16 border-b">
<Navigation />
</nav>
{/* Main content area */}
<div className="flex-1 flex items-stretch min-h-0">
{/* Sidebar */}
<aside className="w-64 flex-shrink-0 border-r overflow-auto">
<SidebarContent />
</aside>
{/* Main content with internal scroll */}
<main className="flex-1 min-w-0 overflow-auto">
{children}
</main>
</div>
</div>
);
}
```
## Key Takeaways
1. **Always test production builds locally** before deployment using `pnpm build && pnpm start`
2. **Be explicit with flexbox properties** - Don't rely on browser defaults or bundler behavior
3. **Use `items-stretch` explicitly** on containers where children need to fill space
4. **Remember `min-h-0` and `min-w-0`** for scrollable flex children
5. **Separate layout responsibilities** between parent (container behavior) and child (self behavior)
6. **Document layout patterns** in your project to ensure consistency across the team
## Related Resources
- [CSS Flexbox Guide](https://css-tricks.com/snippets/css/a-guide-to-flexbox/)
- [Next.js Turbopack Documentation](https://nextjs.org/docs/architecture/turbopack)
- [Tailwind CSS Flexbox Utilities](https://tailwindcss.com/docs/flex)

View File

@@ -0,0 +1,218 @@
# WebKit Tap Highlight and Border-Radius Issues on Mobile
## Problem
Buttons and interactive elements lose their `border-radius` styling when tapped on mobile devices (iOS Safari, Chrome on iOS). The element briefly shows a rectangular highlight instead of respecting the rounded corners.
**Symptoms:**
- Button appears with sharp corners during tap/touch
- A blue or gray rectangular overlay flashes on touch
- The visual glitch only occurs on WebKit-based mobile browsers
- Desktop browsers and Android Chrome don't show the issue
## Root Cause
WebKit browsers apply a default tap highlight effect to interactive elements. This highlight:
1. **Ignores `border-radius`** - The highlight is applied as a simple rectangular overlay
2. **Uses system default color** - Typically a semi-transparent blue or gray
3. **Overrides visual styling** - The highlight appears on top of your custom styles
### Technical Details
When you tap an element on iOS Safari:
```css
/* WebKit's default behavior (pseudo-representation) */
element:active {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
/* This creates a RECTANGULAR overlay, ignoring border-radius */
}
```
The tap highlight is rendered as a separate layer that doesn't respect the element's `border-radius`, `clip-path`, or other shape-defining properties.
## Solution
### Solution 1: Disable Tap Highlight + Wrapper with Overflow Hidden
The most reliable solution combines two techniques:
```tsx
// Button component with proper mobile touch handling
function Button({ children, className, ...props }: ButtonProps) {
return (
<div className="rounded-lg overflow-hidden inline-block">
<button
className={cn("rounded-lg px-4 py-2 bg-blue-500 text-white", className)}
style={{ WebkitTapHighlightColor: "transparent" }}
{...props}
>
{children}
</button>
</div>
);
}
```
**Why this works:**
1. `WebkitTapHighlightColor: "transparent"` removes the default highlight
2. The wrapper `div` with `overflow-hidden` clips any remaining visual artifacts
3. Both elements have matching `border-radius` for consistent appearance
### Solution 2: CSS-Only Approach
If you can't modify the component structure:
```css
/* In your global CSS */
.tap-safe {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
/* Custom active state to replace the highlight */
.tap-safe:active {
opacity: 0.8;
transform: scale(0.98);
}
```
```tsx
<button className="tap-safe rounded-lg px-4 py-2 bg-blue-500">
Click me
</button>
```
### Solution 3: Tailwind CSS Utility Class
Add a reusable utility in your Tailwind config:
```javascript
// tailwind.config.js
module.exports = {
theme: {
extend: {},
},
plugins: [
function({ addUtilities }) {
addUtilities({
'.tap-highlight-none': {
'-webkit-tap-highlight-color': 'transparent',
},
});
},
],
};
```
Then use it in components:
```tsx
<button className="tap-highlight-none rounded-lg px-4 py-2">
Click me
</button>
```
### Solution 4: Wrapper Component for Consistent Behavior
Create a reusable wrapper for all interactive rounded elements:
```tsx
// components/ui/touch-safe-wrapper.tsx
interface TouchSafeWrapperProps {
children: React.ReactNode;
className?: string;
borderRadius?: string;
}
export function TouchSafeWrapper({
children,
className,
borderRadius = "rounded-lg"
}: TouchSafeWrapperProps) {
return (
<div className={cn(borderRadius, "overflow-hidden inline-flex", className)}>
{children}
</div>
);
}
// Usage
<TouchSafeWrapper>
<button
className="rounded-lg px-4 py-2 bg-blue-500"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Click me
</button>
</TouchSafeWrapper>
```
### Complete Example: Card with Clickable Areas
```tsx
function ProductCard({ product }: { product: Product }) {
return (
<div className="rounded-xl border p-4">
<h3>{product.name}</h3>
<p>{product.description}</p>
{/* Action buttons with tap-safe handling */}
<div className="flex gap-2 mt-4">
<div className="rounded-lg overflow-hidden">
<button
className="rounded-lg px-4 py-2 bg-blue-500 text-white"
style={{ WebkitTapHighlightColor: "transparent" }}
onClick={() => addToCart(product)}
>
Add to Cart
</button>
</div>
<div className="rounded-lg overflow-hidden">
<button
className="rounded-lg px-4 py-2 border border-gray-300"
style={{ WebkitTapHighlightColor: "transparent" }}
onClick={() => viewDetails(product)}
>
Details
</button>
</div>
</div>
</div>
);
}
```
## Key Takeaways
1. **WebKit tap highlight ignores border-radius** - This is browser behavior, not a CSS bug
2. **Always set `WebkitTapHighlightColor: "transparent"`** on interactive elements with rounded corners
3. **Use a wrapper with `overflow-hidden`** for the most reliable visual clipping
4. **Test on actual iOS devices** - Simulators and browser dev tools may not reproduce the issue
5. **Consider adding custom active states** to replace the removed tap feedback for better UX
6. **Create reusable components** that handle mobile touch behavior consistently
## Browser Support Notes
| Browser | Needs Fix |
|---------|-----------|
| iOS Safari | Yes |
| Chrome on iOS | Yes (uses WebKit) |
| Firefox on iOS | Yes (uses WebKit) |
| Android Chrome | Usually no |
| Desktop browsers | No |
## Related Resources
- [MDN: -webkit-tap-highlight-color](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color)
- [WebKit Bug Tracker](https://bugs.webkit.org/)
- [CSS Tricks: Handling Touch Events](https://css-tricks.com/snippets/css/remove-gray-highlight-when-tapping-links-in-mobile-safari/)