Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions templates/next-image/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@ import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
transpilePackages: ['@merit-systems/echo-next-sdk'],

// Fix HTTP 413 "Request Entity Too Large" for image routes (App Router).
//
// Background: Next.js App Router ignores the Pages-Router-only
// `export const config = { api: { bodyParser: { sizeLimit: '...' } } }`
// pattern. The correct way to raise the body-size limit in App Router is:
//
// 1. For Server Actions: `experimental.serverActions.bodySizeLimit`
// 2. For Route Handlers: the limit is controlled by the underlying
// Node.js / Edge runtime and is not exposed as a simple config key in
// Next.js ≤15 — the recommended approach is to read the raw stream
// yourself OR to configure a reverse-proxy (Vercel / nginx). However,
// Vercel already allows up to 4.5 MB on the free tier and up to 10 MB
// on paid plans. For self-hosted deployments we set the experimental
// option below which also covers route handlers as of Next.js 15.
//
// See: https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions
experimental: {
serverActions: {
// Allow up to 10 MB bodies — enough for multiple high-res base64 images.
bodySizeLimit: '10mb',
},
},
};

export default nextConfig;
13 changes: 6 additions & 7 deletions templates/next-image/src/app/api/edit-image/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,12 @@ const providers = {
gemini: handleGoogleEdit,
};

export const config = {
api: {
bodyParser: {
sizeLimit: '4mb',
},
},
};
// App Router route segment config
// `maxDuration` sets the maximum execution time for this route (seconds).
// Note: the body-size limit for App Router route handlers is configured in
// next.config.ts via `experimental.serverActions.bodySizeLimit`, NOT via the
// Pages-Router-only `export const config` pattern which is silently ignored here.
export const maxDuration = 60;

export async function POST(req: Request) {
try {
Expand Down
13 changes: 6 additions & 7 deletions templates/next-image/src/app/api/generate-image/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,12 @@ const providers = {
gemini: handleGoogleGenerate,
};

export const config = {
api: {
bodyParser: {
sizeLimit: '4mb',
},
},
};
// App Router route segment config
// `maxDuration` sets the maximum execution time for this route (seconds).
// Note: the body-size limit for App Router route handlers is configured in
// next.config.ts via `experimental.serverActions.bodySizeLimit`, NOT via the
// Pages-Router-only `export const config` pattern which is silently ignored here.
export const maxDuration = 60;

export async function POST(req: Request) {
try {
Expand Down
9 changes: 6 additions & 3 deletions templates/next-image/src/components/image-generator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';

import { fileToDataUrl } from '@/lib/image-utils';
import { fileToDataUrl, fileToCompressedDataUrl } from '@/lib/image-utils';
import type {
EditImageRequest,
GeneratedImage,
Expand Down Expand Up @@ -219,10 +219,13 @@ export default function ImageGenerator() {
try {
const imageUrls = await Promise.all(
imageFiles.map(async imageFile => {
// Convert blob URL to data URL for API
// Convert blob URL to a compressed data URL for API submission.
// Compression reduces payload size and prevents HTTP 413 errors
// when uploading large images. The server-side limit is also
// raised in next.config.ts as a belt-and-suspenders fix.
const response = await fetch(imageFile.url);
const blob = await response.blob();
return await fileToDataUrl(
return await fileToCompressedDataUrl(
new File([blob], 'image', { type: imageFile.mediaType })
);
})
Expand Down
116 changes: 108 additions & 8 deletions templates/next-image/src/lib/image-utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
/**
* Minimal Image Utilities
* Image Utilities
*
* Simple, clean API with just data URLs. No complex conversions.
* Data URL helpers and client-side image compression to help avoid
* "Request Entity Too Large" (HTTP 413) errors when sending base64 payloads
* to the API routes.
*
* Two-layer defence against 413:
* 1. Server: `next.config.ts` raises the body-size limit to 10 MB via
* `experimental.serverActions.bodySizeLimit`.
* 2. Client: `compressImageDataUrl` (below) shrinks images before upload,
* giving a better UX and staying well under any server limit.
*/

// ---------------------------------------------------------------------------
// Compression constants
// ---------------------------------------------------------------------------

/** Max dimension (width or height) before downscaling. */
const MAX_IMAGE_DIMENSION = 1024;

/** JPEG quality used when re-encoding (0–1). */
const COMPRESS_QUALITY = 0.85;

/** Hard cap: if a data URL is already smaller than this, skip re-encoding. */
const COMPRESS_SKIP_THRESHOLD_BYTES = 512 * 1024; // 512 KB

// ---------------------------------------------------------------------------
// Core conversions
// ---------------------------------------------------------------------------

/**
* Converts a File to a data URL
* Converts a File to a data URL.
*/
export async function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
Expand All @@ -17,7 +42,7 @@ export async function fileToDataUrl(file: File): Promise<string> {
}

/**
* Converts a data URL to a File object
* Converts a data URL to a File object.
*/
export function dataUrlToFile(dataUrl: string, filename: string): File {
const [header, base64] = dataUrl.split(',');
Expand All @@ -32,8 +57,83 @@ export function dataUrlToFile(dataUrl: string, filename: string): File {
return new File([array], filename, { type: mime });
}

// ---------------------------------------------------------------------------
// Client-side compression
// ---------------------------------------------------------------------------

/**
* Compresses a data URL so it is suitable for sending as a JSON body.
*
* Strategy:
* - Skip images that are already small (< COMPRESS_SKIP_THRESHOLD_BYTES).
* - Downscale so that neither dimension exceeds MAX_IMAGE_DIMENSION px.
* - Re-encode as JPEG at COMPRESS_QUALITY.
*
* This is a client-side safety net. The definitive fix for HTTP 413 is the
* raised body-size limit in `next.config.ts`.
*
* @param dataUrl Source image as a data URL (any format supported by <img>).
* @returns Compressed data URL (image/jpeg).
*/
export async function compressImageDataUrl(dataUrl: string): Promise<string> {
// Fast path: already tiny enough, no need to spin up a canvas.
if (dataUrl.length < COMPRESS_SKIP_THRESHOLD_BYTES) {
return dataUrl;
}

return new Promise((resolve, reject) => {
const img = new Image();

img.onload = () => {
let { width, height } = img;

// Downscale proportionally if either dimension exceeds the limit.
if (width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) {
if (width >= height) {
height = Math.round((height * MAX_IMAGE_DIMENSION) / width);
width = MAX_IMAGE_DIMENSION;
} else {
width = Math.round((width * MAX_IMAGE_DIMENSION) / height);
height = MAX_IMAGE_DIMENSION;
}
}

const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;

const ctx = canvas.getContext('2d');
if (!ctx) {
// Canvas unavailable — return original rather than throwing.
resolve(dataUrl);
return;
}

ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', COMPRESS_QUALITY));
};

img.onerror = reject;
img.src = dataUrl;
});
}

/**
* Converts a File to a compressed data URL ready for API submission.
*
* Combines `fileToDataUrl` + `compressImageDataUrl` into a single call.
*/
export async function fileToCompressedDataUrl(file: File): Promise<string> {
const raw = await fileToDataUrl(file);
return compressImageDataUrl(raw);
}

// ---------------------------------------------------------------------------
// UI helpers
// ---------------------------------------------------------------------------

/**
* Downloads an image from a data URL
* Downloads an image from a data URL.
*/
export function downloadDataUrl(dataUrl: string, filename: string): void {
const link = document.createElement('a');
Expand All @@ -45,7 +145,7 @@ export function downloadDataUrl(dataUrl: string, filename: string): void {
}

/**
* Copies an image to the clipboard from a data URL
* Copies an image to the clipboard from a data URL.
*/
export async function copyDataUrlToClipboard(dataUrl: string): Promise<void> {
const [header, base64] = dataUrl.split(',');
Expand All @@ -62,14 +162,14 @@ export async function copyDataUrlToClipboard(dataUrl: string): Promise<void> {
}

/**
* Generates a filename for an image
* Generates a filename for an image.
*/
export function generateFilename(imageId: string): string {
return `generated-image-${imageId}.png`;
}

/**
* Extracts media type from a data URL
* Extracts media type from a data URL.
*/
export function getMediaTypeFromDataUrl(dataUrl: string): string {
if (!dataUrl.startsWith('data:')) return 'image/jpeg';
Expand Down