Building a Zero-Backend AI Prompt Manager with Next.js 19 and localStorage

by | Mar 16, 2026 | Articles, Projects | 0 comments

Live demo: https://luckytoolkit.com/promptmanager/

If you spend serious time with AI tools — ChatGPT, Claude, Gemini — you quickly accumulate dozens of carefully crafted prompts. They end up scattered across Notion pages, browser bookmarks, and sticky notes. I built Prompt Manager to solve exactly that: a fully-featured prompt library that runs entirely in the browser with zero backend, zero accounts, and zero cost to host.

This post is a complete technical breakdown of how it works.

Here is the source https://github.com/kalyanraju/promptManager

Stack Overview

  • Next.js 16 (App Router) with React 19
  • TypeScript throughout
  • Tailwind CSS v4 for styling
  • No backend — localStorage is the database
  • No icon library — custom inline SVGs
  • No state management library — React built-ins only

The entire application lives in a single file: src/app/page.tsx, approximately 1,400 lines. This was a deliberate architectural choice — one file means zero inter-file dependencies, easy to deploy as a static export, and trivially portable.

Data Model

Every prompt is represented as a flat TypeScript interface:

interface Prompt {
  id: string;
  title: string;
  content: string;
  tags: string[];
  category: string;
  isFavorite: boolean;
  createdAt: number;
  updatedAt: number;
  usageCount: number;
  variables: string[];
}

The variables field is derived automatically by scanning the prompt content for {{variable}} syntax using a regex:

const extractVariables = (content: string): string[] => {
  const matches = content.match(/\{\{(\w+)\}\}/g);
  return matches
    ? [...new Set(matches.map((m) => m.replace(/[{}]/g, "")))]
    : [];
};

This lets users write prompts like:

Write a blog post about {{topic}} for {{audience}} in a {{tone}} tone.

And the app automatically detects and displays the variable names, turning prompts into reusable templates.


Persistence Layer

There is no server. All data lives in two localStorage keys:

const STORAGE_KEY = "ai-prompt-optimizer-data";
const CATEGORIES_STORAGE_KEY = "ai-prompt-optimizer-categories";

The load functions handle SSR safely (Next.js runs components on the server during build):

const loadPrompts = (): Prompt[] => {
  if (typeof window === "undefined") return [];
  try {
    const data = localStorage.getItem(STORAGE_KEY);
    return data ? JSON.parse(data) : SAMPLE_PROMPTS;
  } catch {
    return SAMPLE_PROMPTS;
  }
};

State initialization uses lazy initializers to avoid reading localStorage during SSR:

const [prompts, setPrompts] = useState<Prompt[]>(() => {
  if (typeof window === "undefined") return [];
  return loadPrompts();
});

Saving is triggered by useEffect watching the prompts array:

useEffect(() => {
  if (prompts.length > 0) {
    savePrompts(prompts);
  }
}, [prompts]);

Search and Filter Pipeline

The filtered prompt list is computed with useMemo, chaining four independent filters before sorting:

const filteredPrompts = useMemo(() => {
  let result = [...prompts];

  // 1. Full-text search across title, content, and tags
  if (searchQuery) {
    const query = searchQuery.toLowerCase();
    result = result.filter(
      (p) =>
        p.title.toLowerCase().includes(query) ||
        p.content.toLowerCase().includes(query) ||
        p.tags.some((t) => t.toLowerCase().includes(query))
    );
  }

  // 2. Category filter
  if (selectedCategory) {
    result = result.filter((p) => p.category === selectedCategory);
  }

  // 3. Multi-select tag filter (OR logic)
  if (selectedTags.length > 0) {
    result = result.filter((p) =>
      selectedTags.some((tag) => p.tags.includes(tag))
    );
  }

  // 4. Favorites filter
  if (showFavorites) {
    result = result.filter((p) => p.isFavorite);
  }

  // Sort: recent / most-used / alphabetical
  switch (sortBy) {
    case "recent":
      result.sort((a, b) => b.updatedAt - a.updatedAt);
      break;
    case "usage":
      result.sort((a, b) => b.usageCount - a.usageCount);
      break;
    case "name":
      result.sort((a, b) => a.title.localeCompare(b.title));
      break;
  }

  return result;
}, [prompts, searchQuery, selectedCategory, selectedTags, showFavorites, sortBy]);

Category and tag counts for the sidebar are also memoized:

const categoryCounts = useMemo(() => {
  const counts: Record<string, number> = {};
  prompts.forEach((p) => {
    counts[p.category] = (counts[p.category] || 0) + 1;
  });
  return counts;
}, [prompts]);

The Prompt Optimizer

This is the most interesting feature. It’s a purely algorithmic analyzer — no AI API, no network call — that inspects prompt text and returns structured suggestions:

interface OptimizerSuggestion {
  type: "clarity" | "structure" | "specificity" | "context" | "output";
  message: string;
  improvement?: string;
}

The analysis runs five heuristic checks:

const analyzePrompt = (content: string): OptimizerSuggestion[] => {
  const suggestions: OptimizerSuggestion[] = [];

  // 1. Too short — likely missing context
  if (content.length < 50) {
    suggestions.push({
      type: "clarity",
      message: "Prompt is quite short. Consider adding more context and details.",
      improvement: "Expand with specific instructions, context, and expected output format.",
    });
  }

  // 2. Wall of text — no structure
  if (!content.includes("\n") && content.length > 100) {
    suggestions.push({
      type: "structure",
      message: "Consider breaking down your prompt into sections for better clarity.",
      improvement: "Use bullet points or numbered lists to organize instructions.",
    });
  }

  // 3. Vague language
  const vagueWords = ["good", "nice", "better", "appropriate", "suitable"];
  if (vagueWords.some((w) => content.toLowerCase().includes(w))) {
    suggestions.push({
      type: "specificity",
      message: "Some words are vague. Use specific, measurable criteria.",
      improvement: 'Replace "good" with specific quality metrics or examples.',
    });
  }

  // 4. Missing context signal
  if (!content.toLowerCase().includes("context") &&
      !content.toLowerCase().includes("background")) {
    suggestions.push({
      type: "context",
      message: "Consider adding context about the use case or target audience.",
    });
  }

  // 5. No output format specified
  if (!content.toLowerCase().includes("format") &&
      !content.toLowerCase().includes("output")) {
    suggestions.push({
      type: "output",
      message: "Specify the expected output format for more consistent results.",
      improvement: 'Add: "Format your response as..." or "Output should include..."',
    });
  }

  return suggestions;
};

The auto-optimize function applies structural transformations:

const optimizePrompt = (content: string): string => {
  let optimized = content;

  // Break wall-of-text into paragraphs
  if (!content.includes("\n\n") && content.length > 100) {
    const sentences = content.split(". ");
    if (sentences.length > 3) {
      optimized = sentences.join(".\n\n");
    }
  }

  // Append output format reminder if absent
  if (!content.toLowerCase().includes("format") &&
      !content.toLowerCase().includes("output")) {
    optimized += "\n\nProvide your response in a clear, structured format.";
  }

  return optimized;
};

Export / Import

The export handler serializes the full prompts array to a timestamped JSON file using the Blob + Object URL pattern — no server required:

const handleExportPrompts = useCallback(() => {
  const data = JSON.stringify(prompts, null, 2);
  const blob = new Blob([data], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `prompts-export-${new Date().toISOString().split("T")[0]}.json`;
  a.click();
  URL.revokeObjectURL(url);
}, [prompts]);

Import validates the structure and re-extracts variables in case the imported file is from an older schema version:

const validPrompts = imported.map((p) => ({
  ...p,
  id: p.id || generateId(),
  variables: extractVariables(p.content), // re-derive, don't trust import
}));
setPrompts((prev) => [...validPrompts, ...prev]);

Custom Category Management

On top of 8 built-in categories (General, Code Generation, Writing, Analysis, Creative, Business, Education, Research), users can create their own. Custom categories persist to their own localStorage key separately from prompts.

Deleting a custom category safely migrates its prompts to “General”:

setPrompts((prev) =>
  prev.map((p) =>
    p.category === category ? { ...p, category: "General" } : p
  )
);

Renaming cascades across all prompts that reference the old name.


Usage Tracking

Every time a user copies a prompt, the usageCount increments:

const handleCopyPrompt = useCallback((prompt: Prompt) => {
  navigator.clipboard.writeText(prompt.content);
  setCopiedId(prompt.id);
  setTimeout(() => setCopiedId(null), 2000); // visual feedback

  setPrompts((prev) =>
    prev.map((p) =>
      p.id === prompt.id ? { ...p, usageCount: p.usageCount + 1 } : p
    )
  );
}, []);

This feeds the “Most Used” sort, helping you surface the prompts that actually matter.


Dark Mode

Theme preference is initialized from localStorage with a fallback to the OS prefers-color-scheme media query:

const [isDarkMode, setIsDarkMode] = useState(() => {
  if (typeof window === "undefined") return false;
  const savedTheme = localStorage.getItem("ai-prompt-optimizer-theme");
  if (savedTheme) return savedTheme === "dark";
  return window.matchMedia("(prefers-color-scheme: dark)").matches;
});

Tailwind’s class-based dark mode is toggled by adding/removing the dark class on documentElement:

useEffect(() => {
  document.documentElement.classList.toggle("dark", isDarkMode);
  localStorage.setItem("ai-prompt-optimizer-theme", isDarkMode ? "dark" : "light");
}, [isDarkMode]);

Layout and UI

The layout is a responsive 4-column grid (1 sidebar + 3 content columns on large screens, single column on mobile):

<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
  <aside className="lg:col-span-1">/* filters, categories, tags */</aside>
  <main className="lg:col-span-3">/* prompt cards + detail panel */</main>
</div>

The header is sticky with a subtle backdrop blur:

<header className="sticky top-0 z-40 border-b bg-white/95 backdrop-blur-sm">

All icons are hand-crafted inline SVG components to keep the bundle lean — no Lucide, no Heroicons, no font-awesome dependency.


Performance Considerations

  • All derived state (filtered lists, tag counts, category counts) is wrapped in useMemo with precise dependency arrays
  • All event handlers use useCallback to prevent unnecessary re-renders of card components
  • State lazy initialization prevents synchronous localStorage reads during React’s render phase
  • useRef for the hidden file input avoids state churn on upload interactions

Deployment

Built as a Next.js static export (next build), the output is plain HTML/CSS/JS — no Node.js server required. Hosted on a static file server under /promptmanager/.


What I’d Add Next

  • Import from URL — paste a shared prompt link
  • Prompt versioning — track edit history per prompt
  • Variable fill-in UI — form that replaces {{variable}} before copying
  • Cloud sync — optional account-based backup (Supabase or similar)
  • Prompt scoring — track which prompts produce the best AI outputs

Try it live: https://luckytoolkit.com/promptmanager/

All your prompts stay in your browser. No account. No server. No data leaves your machine.

Written by

Related Posts

0 Comments

Submit a Comment

Your email address will not be published. Required fields are marked *