Skip to content

Instantly share code, notes, and snippets.

@ahmadnaser
Created August 20, 2025 09:32
Show Gist options
  • Select an option

  • Save ahmadnaser/981e7e2b46de00311ad4c2bdfe5eeedd to your computer and use it in GitHub Desktop.

Select an option

Save ahmadnaser/981e7e2b46de00311ad4c2bdfe5eeedd to your computer and use it in GitHub Desktop.

Complete Next.js Textarea to MongoDB Solution

Project Structure

my-textarea-app/
├── package.json
├── .env.local
├── next.config.js
├── tailwind.config.ts
├── tsconfig.json
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   └── texts/
│   │   │       └── route.ts
│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── texts/
│   │       └── page.tsx
│   ├── components/
│   │   ├── TextEditor.tsx
│   │   └── TextList.tsx
│   ├── lib/
│   │   └── mongodb.ts
│   └── models/
│       └── Text.ts

1. package.json

{
  "name": "textarea-mongodb-nextjs",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "14.0.3",
    "react": "^18",
    "react-dom": "^18",
    "mongodb": "^6.3.0",
    "mongoose": "^8.0.3"
  },
  "devDependencies": {
    "typescript": "^5",
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "postcss": "^8",
    "tailwindcss": "^3.3.0",
    "eslint": "^8",
    "eslint-config-next": "14.0.3"
  }
}

2. .env.local

MONGODB_URI=mongodb://localhost:27017/textarea-app
# Or use MongoDB Atlas:
# MONGODB_URI=mongodb+srv://username:[email protected]/textarea-app

3. next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ['mongoose']
  }
}

module.exports = nextConfig

4. tailwind.config.ts

import type { Config } from 'tailwindcss'

const config: Config = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
export default config

5. tsconfig.json

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "es6"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

6. src/app/globals.css

@tailwind base;
@tailwind components;
@tailwind utilities;

7. src/lib/mongodb.ts

import mongoose from 'mongoose';

const MONGODB_URI = process.env.MONGODB_URI!;

if (!MONGODB_URI) {
  throw new Error('Please define the MONGODB_URI environment variable inside .env.local');
}

let cached = (global as any).mongoose;

if (!cached) {
  cached = (global as any).mongoose = { conn: null, promise: null };
}

async function connectDB() {
  if (cached.conn) {
    return cached.conn;
  }

  if (!cached.promise) {
    const opts = {
      bufferCommands: false,
    };

    cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
      return mongoose;
    });
  }

  try {
    cached.conn = await cached.promise;
  } catch (e) {
    cached.promise = null;
    throw e;
  }

  return cached.conn;
}

export default connectDB;

8. src/models/Text.ts

import mongoose from 'mongoose';

export interface IText extends mongoose.Document {
  content: string;
  title?: string;
  createdAt: Date;
  updatedAt: Date;
}

const TextSchema = new mongoose.Schema({
  content: {
    type: String,
    required: [true, 'Content is required'],
  },
  title: {
    type: String,
    default: 'Untitled',
  },
  createdAt: {
    type: Date,
    default: Date.now,
  },
  updatedAt: {
    type: Date,
    default: Date.now,
  },
});

export default mongoose.models.Text || mongoose.model<IText>('Text', TextSchema);

9. src/app/api/texts/route.ts

import { NextRequest, NextResponse } from 'next/server';
import connectDB from '@/lib/mongodb';
import Text from '@/models/Text';

// GET - Fetch all texts
export async function GET() {
  try {
    await connectDB();
    const texts = await Text.find({}).sort({ createdAt: -1 });
    return NextResponse.json({ success: true, data: texts });
  } catch (error) {
    console.error('Error fetching texts:', error);
    return NextResponse.json(
      { success: false, error: 'Failed to fetch texts' },
      { status: 500 }
    );
  }
}

// POST - Create new text
export async function POST(request: NextRequest) {
  try {
    const { content, title } = await request.json();

    if (!content) {
      return NextResponse.json(
        { success: false, error: 'Content is required' },
        { status: 400 }
      );
    }

    await connectDB();
    const newText = await Text.create({
      content,
      title: title || 'Untitled',
    });

    return NextResponse.json(
      { success: true, data: newText, message: 'Text saved successfully' },
      { status: 201 }
    );
  } catch (error) {
    console.error('Error creating text:', error);
    return NextResponse.json(
      { success: false, error: 'Failed to save text' },
      { status: 500 }
    );
  }
}

// PUT - Update text
export async function PUT(request: NextRequest) {
  try {
    const { id, content, title } = await request.json();

    if (!id || !content) {
      return NextResponse.json(
        { success: false, error: 'ID and content are required' },
        { status: 400 }
      );
    }

    await connectDB();
    const updatedText = await Text.findByIdAndUpdate(
      id,
      { 
        content, 
        title: title || 'Untitled',
        updatedAt: new Date() 
      },
      { new: true }
    );

    if (!updatedText) {
      return NextResponse.json(
        { success: false, error: 'Text not found' },
        { status: 404 }
      );
    }

    return NextResponse.json({
      success: true,
      data: updatedText,
      message: 'Text updated successfully',
    });
  } catch (error) {
    console.error('Error updating text:', error);
    return NextResponse.json(
      { success: false, error: 'Failed to update text' },
      { status: 500 }
    );
  }
}

// DELETE - Delete text
export async function DELETE(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    const id = searchParams.get('id');

    if (!id) {
      return NextResponse.json(
        { success: false, error: 'ID is required' },
        { status: 400 }
      );
    }

    await connectDB();
    const deletedText = await Text.findByIdAndDelete(id);

    if (!deletedText) {
      return NextResponse.json(
        { success: false, error: 'Text not found' },
        { status: 404 }
      );
    }

    return NextResponse.json({
      success: true,
      message: 'Text deleted successfully',
    });
  } catch (error) {
    console.error('Error deleting text:', error);
    return NextResponse.json(
      { success: false, error: 'Failed to delete text' },
      { status: 500 }
    );
  }
}

10. src/components/TextEditor.tsx

'use client';

import { useState } from 'react';

interface TextEditorProps {
  onSave: () => void;
}

export default function TextEditor({ onSave }: TextEditorProps) {
  const [content, setContent] = useState('');
  const [title, setTitle] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [message, setMessage] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!content.trim()) {
      setMessage('Please enter some content');
      return;
    }

    setIsLoading(true);
    
    try {
      const response = await fetch('/api/texts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          content: content.trim(),
          title: title.trim() || 'Untitled',
        }),
      });

      const result = await response.json();

      if (result.success) {
        setMessage('Text saved successfully!');
        setContent('');
        setTitle('');
        onSave(); // Refresh the list
      } else {
        setMessage(result.error || 'Failed to save text');
      }
    } catch (error) {
      setMessage('Error saving text');
      console.error('Error:', error);
    } finally {
      setIsLoading(false);
      // Clear message after 3 seconds
      setTimeout(() => setMessage(''), 3000);
    }
  };

  const handleClear = () => {
    setContent('');
    setTitle('');
    setMessage('');
  };

  return (
    <div className="bg-white rounded-lg shadow-md p-6 mb-8">
      <h2 className="text-xl font-semibold text-gray-700 mb-4">Add New Text</h2>
      
      {message && (
        <div className={`mb-4 p-4 rounded-lg ${
          message.includes('successfully') 
            ? 'bg-green-100 text-green-700 border border-green-300' 
            : 'bg-red-100 text-red-700 border border-red-300'
        }`}>
          {message}
        </div>
      )}

      <form onSubmit={handleSubmit}>
        <div className="mb-4">
          <input
            type="text"
            placeholder="Enter title (optional)"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            disabled={isLoading}
          />
        </div>
        
        <textarea
          placeholder="Enter your text content here..."
          value={content}
          onChange={(e) => setContent(e.target.value)}
          className="w-full h-40 p-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical"
          required
          disabled={isLoading}
        />
        
        <div className="mt-4 flex gap-4">
          <button
            type="submit"
            disabled={isLoading}
            className="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-6 py-2 rounded-lg transition duration-200"
          >
            {isLoading ? 'Saving...' : 'Save Text'}
          </button>
          <button
            type="button"
            onClick={handleClear}
            disabled={isLoading}
            className="bg-gray-500 hover:bg-gray-600 disabled:bg-gray-300 text-white px-6 py-2 rounded-lg transition duration-200"
          >
            Clear
          </button>
        </div>
      </form>
    </div>
  );
}

11. src/components/TextList.tsx

'use client';

import { useState, useEffect } from 'react';

interface Text {
  _id: string;
  title: string;
  content: string;
  createdAt: string;
  updatedAt: string;
}

interface TextListProps {
  refresh: boolean;
  onRefreshComplete: () => void;
}

export default function TextList({ refresh, onRefreshComplete }: TextListProps) {
  const [texts, setTexts] = useState<Text[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState('');

  const fetchTexts = async () => {
    try {
      setIsLoading(true);
      const response = await fetch('/api/texts');
      const result = await response.json();

      if (result.success) {
        setTexts(result.data);
        setError('');
      } else {
        setError(result.error || 'Failed to fetch texts');
      }
    } catch (err) {
      setError('Error fetching texts');
      console.error('Error:', err);
    } finally {
      setIsLoading(false);
    }
  };

  const deleteText = async (id: string) => {
    if (!confirm('Are you sure you want to delete this text?')) {
      return;
    }

    try {
      const response = await fetch(`/api/texts?id=${id}`, {
        method: 'DELETE',
      });

      const result = await response.json();

      if (result.success) {
        setTexts(texts.filter(text => text._id !== id));
      } else {
        setError(result.error || 'Failed to delete text');
      }
    } catch (err) {
      setError('Error deleting text');
      console.error('Error:', err);
    }
  };

  useEffect(() => {
    fetchTexts();
  }, []);

  useEffect(() => {
    if (refresh) {
      fetchTexts();
      onRefreshComplete();
    }
  }, [refresh, onRefreshComplete]);

  if (isLoading) {
    return (
      <div className="bg-white rounded-lg shadow-md p-6">
        <div className="flex justify-center items-center h-32">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
          <span className="ml-2">Loading texts...</span>
        </div>
      </div>
    );
  }

  return (
    <div className="bg-white rounded-lg shadow-md p-6">
      <div className="flex justify-between items-center mb-4">
        <h2 className="text-xl font-semibold text-gray-700">Saved Texts ({texts.length})</h2>
        <button
          onClick={fetchTexts}
          className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg transition duration-200"
        >
          Refresh
        </button>
      </div>

      {error && (
        <div className="mb-4 p-4 bg-red-100 text-red-700 border border-red-300 rounded-lg">
          {error}
        </div>
      )}

      {texts.length === 0 ? (
        <div className="text-center text-gray-500 py-8">
          No texts saved yet. Create your first text above!
        </div>
      ) : (
        <div className="space-y-4">
          {texts.map((text) => (
            <div key={text._id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
              <div className="flex justify-between items-start mb-2">
                <h3 className="font-semibold text-gray-800">{text.title}</h3>
                <button
                  onClick={() => deleteText(text._id)}
                  className="text-red-500 hover:text-red-700 transition-colors"
                  title="Delete text"
                >
                  <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
                  </svg>
                </button>
              </div>
              <p className="text-gray-600 whitespace-pre-wrap">{text.content}</p>
              <div className="mt-3 text-sm text-gray-400">
                Created: {new Date(text.createdAt).toLocaleString()}
                {text.updatedAt !== text.createdAt && (
                  <span className="ml-4">
                    Updated: {new Date(text.updatedAt).toLocaleString()}
                  </span>
                )}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

12. src/app/layout.tsx

import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: 'Textarea to MongoDB',
  description: 'Simple text content manager with MongoDB',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-gray-100 min-h-screen">{children}</body>
    </html>
  );
}

13. src/app/page.tsx

'use client';

import { useState } from 'react';
import TextEditor from '@/components/TextEditor';
import TextList from '@/components/TextList';

export default function Home() {
  const [refreshList, setRefreshList] = useState(false);

  const handleTextSaved = () => {
    setRefreshList(true);
  };

  const handleRefreshComplete = () => {
    setRefreshList(false);
  };

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold text-gray-800 mb-8 text-center">
          Text Content Manager
        </h1>
        
        <TextEditor onSave={handleTextSaved} />
        <TextList refresh={refreshList} onRefreshComplete={handleRefreshComplete} />
      </div>
    </div>
  );
}

14. src/app/texts/page.tsx (Optional separate page)

import { redirect } from 'next/navigation';

export default function TextsPage() {
  redirect('/');
}

Installation & Setup Steps

  1. Create the project:
npx create-next-app@latest textarea-mongodb-app --typescript --tailwind --eslint --app --src-dir
cd textarea-mongodb-app
  1. Install dependencies:
npm install mongodb mongoose
  1. Create all the files above in their respective locations

  2. Set up MongoDB:

    • Install MongoDB locally or use MongoDB Atlas
    • Create the .env.local file with your MongoDB connection string
  3. Run the development server:

npm run dev
  1. Visit your app: Open http://localhost:3000 in your browser

Features Included

  • ✅ Create and save text content with optional titles
  • ✅ View all saved texts with timestamps
  • ✅ Delete individual texts
  • ✅ Responsive design with Tailwind CSS
  • ✅ Loading states and error handling
  • ✅ TypeScript support
  • ✅ MongoDB connection with Mongoose
  • ✅ RESTful API endpoints
  • ✅ Server-side rendering with Next.js 14

Your textarea content will be automatically saved to your MongoDB database!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment