├── app ├── globals.css ├── layout.tsx └── page.tsx ├── postcss.config.mjs ├── next.config.ts ├── .gitignore ├── tsconfig.json ├── package.json ├── pages └── api │ └── crawl.ts ├── README.md └── components ├── SearchComponent.tsx └── RecentUpdates.tsx /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Upstash Docs Library", 17 | description: "A modern documentation library to search and track the docs you like.", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "documentation-library", 3 | "version": "0.1.0", 4 | "description": "A modern documentation library to search and track the docs.", 5 | "private": true, 6 | "scripts": { 7 | "dev": "next dev --turbopack", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@upstash/search": "^0.1.3", 14 | "@upstash/search-crawler": "^0.1.8", 15 | "@upstash/search-ui": "^0.1.4", 16 | "lucide-react": "^0.525.0", 17 | "next": "15.3.8", 18 | "react": "^19.0.0", 19 | "react-dom": "^19.0.0" 20 | }, 21 | "devDependencies": { 22 | "@tailwindcss/postcss": "^4", 23 | "@types/node": "^20", 24 | "@types/react": "^19", 25 | "@types/react-dom": "^19", 26 | "tailwindcss": "^4", 27 | "tw-animate-css": "^1.3.5", 28 | "typescript": "^5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/api/crawl.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | import { crawlAndIndex } from "@upstash/search-crawler" 3 | 4 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 5 | if (req.method === 'POST') { 6 | const { docsUrl, index } = req.body; 7 | const upstashUrl = process.env.NEXT_PUBLIC_UPSTASH_SEARCH_URL! 8 | const upstashRestToken = process.env.UPSTASH_SEARCH_REST_TOKEN! 9 | 10 | if (!docsUrl || !upstashUrl || !upstashRestToken) { 11 | return res.status(500).json({ error: "Missing Upstash Search configuration" }); 12 | } 13 | 14 | const result = await crawlAndIndex({ 15 | upstashUrl, 16 | upstashToken: upstashRestToken, 17 | indexName: index || "default", 18 | docUrl: docsUrl, 19 | }) 20 | 21 | return res.status(200).json(result) 22 | } else { 23 | return res.status(405).json({ error: "Method not allowed" }); 24 | } 25 | } -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import SearchComponent from "../components/SearchComponent" 2 | import RecentUpdates from "../components/RecentUpdates" 3 | import { BookOpen } from "lucide-react" 4 | 5 | export default function Page() { 6 | return ( 7 |
8 |
9 |
10 |
11 |
12 | 13 |

Documentation Library

14 |
15 |

16 | Search across all your documentation sources and discover the latest updates 17 |

18 |
19 |
20 |
21 | 22 |
23 | 24 | 25 |
26 |
27 | ) 28 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fupstash%2Fsearch-docs&env=NEXT_PUBLIC_UPSTASH_SEARCH_URL,NEXT_PUBLIC_UPSTASH_SEARCH_READONLY_TOKEN,UPSTASH_SEARCH_REST_TOKEN&envDescription=Credentials%20needed%20for%20Upstash%20Search%20Component%20use&envLink=https%3A%2F%2Fconsole.upstash.com%2Fsearch&project-name=search-docs&repository-name=search-docs&demo-title=Documentation%20Library&demo-description=Search%20across%20all%20your%20documentation%20sources%20and%20discover%20the%20latest%20updates&demo-url=https%3A%2F%2Fsearch-docs.vercel.app%2F) 2 | ## Description 3 | 4 | A modern documentation library to search and track the docs. 5 | 6 | ## Getting Started 7 | 8 | First, run the development server: 9 | 10 | ```bash 11 | npm install 12 | npm run dev 13 | ``` 14 | 15 | ## How it Works 16 | - Search: Uses Upstash Search UI to query multiple indexes in parallel, sorts and groups results, and displays them with section headers. 17 | - Recent Updates: Upstash Qstash fetches all documents from multiple indexes in batches, filters for those crawled in the last 24 hours. 18 | 19 | ## Set the Crawler 20 | 21 | - Upstash Qstash can call this endpoint: `/api/crawl` on schedule to crawl the relevant data 22 | - Providing the URL and the index name in the body, you may manage the crawler, 23 | e.g. 24 | 25 | ``` 26 | { 27 | "docsUrl": "https://nextjs.org/docs", 28 | "index": "next-js" 29 | } 30 | ``` 31 | 32 | ## Conclusion 33 | Finally, the UI will make use of these components to serve users to find whatever they want from 34 | any source they want. Moreover, they can keep up with the updates in their favorite docs. 35 | 36 | 37 | -------------------------------------------------------------------------------- /components/SearchComponent.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SearchBar } from "@upstash/search-ui" 4 | import "@upstash/search-ui/dist/index.css" 5 | import { Search } from "@upstash/search" 6 | import { FileText } from "lucide-react" 7 | 8 | // Initialize Upstash Search client 9 | const client = new Search({ 10 | url: process.env.NEXT_PUBLIC_UPSTASH_SEARCH_URL || "", 11 | token: process.env.NEXT_PUBLIC_UPSTASH_SEARCH_READONLY_TOKEN || "", 12 | }) 13 | 14 | interface SearchResult { 15 | id: string 16 | content: { 17 | title: string 18 | fullContent: string 19 | } 20 | metadata: { 21 | url: string 22 | path: string 23 | contentLength: number 24 | crawledAt: string 25 | } 26 | score: number 27 | indexName?: string 28 | } 29 | 30 | 31 | async function searchDocs(query: string): Promise { 32 | if (!query.trim()) return [] 33 | 34 | try { 35 | const indexes = await client.listIndexes() 36 | 37 | const searchPromises = indexes.map(async (indexName) => { 38 | try { 39 | const index = client.index(indexName) 40 | const searchParams: any = { 41 | query, 42 | limit: 10, 43 | reranking: true 44 | } 45 | 46 | const results = await index.search(searchParams) 47 | 48 | return (results as any[]).map((result, i) => ({ 49 | ...result, 50 | id: `${indexName}-${result.id}`, 51 | indexName 52 | })) 53 | } catch (error) { 54 | console.error(`Error searching ${indexName}:`, error) 55 | return [] 56 | } 57 | }) 58 | 59 | const resultArrays = await Promise.all(searchPromises) 60 | 61 | const allResults = resultArrays.flat() as SearchResult[] 62 | 63 | const topResults = allResults 64 | .sort((a, b) => (b.score || 0) - (a.score || 0)) 65 | .slice(0, 10) 66 | 67 | return topResults 68 | 69 | } catch (error) { 70 | console.error('Search error:', error) 71 | return [] 72 | } 73 | } 74 | 75 | 76 | export default function SearchComponent() { 77 | 78 | return ( 79 |
80 | {/* Search Bar */} 81 |
82 | 83 | 84 | 85 | 86 | 87 | 90 | {(result) => ( 91 | 92 | 93 | 94 | 95 | 96 | 97 | { 98 | window.open(result.metadata?.url, "_blank") 99 | }}> 100 | {result.content?.title} 101 | 102 |

{result.indexName}

103 |
104 |
105 | )} 106 |
107 |
108 |
109 |
110 |
111 | ) 112 | } -------------------------------------------------------------------------------- /components/RecentUpdates.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Search } from "@upstash/search" 4 | import { FileText, Clock } from "lucide-react" 5 | import { useState, useEffect } from "react" 6 | 7 | // Initialize Upstash Search client 8 | const client = new Search({ 9 | url: process.env.NEXT_PUBLIC_UPSTASH_SEARCH_URL || "", 10 | token: process.env.NEXT_PUBLIC_UPSTASH_SEARCH_READONLY_TOKEN || "", 11 | }) 12 | 13 | interface SearchResult { 14 | id: string 15 | content: { 16 | title: string 17 | fullContent: string 18 | } 19 | metadata: { 20 | url: string 21 | path: string 22 | contentLength: number 23 | crawledAt: string 24 | } 25 | score: number 26 | indexName?: string 27 | } 28 | 29 | async function getLatestDocs(): Promise { 30 | try { 31 | const indexes = await client.listIndexes() 32 | 33 | const currentDate = new Date() 34 | currentDate.setDate(currentDate.getDate() - 7) 35 | const oneDayAgo = currentDate.getTime() 36 | 37 | const rangePromises = indexes.map(async (indexName) => { 38 | try { 39 | const index = client.index(indexName) 40 | let recentDocuments: SearchResult[] = [] 41 | let cursor = "" 42 | 43 | while (true) { 44 | const results = await index.range({ 45 | cursor: cursor, 46 | limit: 100 47 | }) 48 | //TODO: use metadata filter instead as param 49 | const recentBatch = results.documents 50 | .filter(result => { 51 | if (!result.metadata?.crawledAt) return false 52 | const crawledTime = new Date(result.metadata.crawledAt as string).getTime() 53 | return crawledTime >= oneDayAgo 54 | }) 55 | .map((result) => ({ 56 | ...result, 57 | id: `${indexName}-${result.id}`, 58 | indexName 59 | })) as SearchResult[] 60 | 61 | recentDocuments = recentDocuments.concat(recentBatch) 62 | 63 | if (!results.nextCursor || results.nextCursor === cursor || recentDocuments.length >= 10) { 64 | break 65 | } 66 | cursor = results.nextCursor 67 | } 68 | 69 | return recentDocuments.slice(0, 10) 70 | } catch (error) { 71 | console.error(`Error getting documents from ${indexName}:`, error) 72 | return [] 73 | } 74 | }) 75 | 76 | const allResultArrays = await Promise.all(rangePromises) 77 | 78 | const allResults = allResultArrays.flat() as SearchResult[] 79 | 80 | return allResults 81 | 82 | } catch (error) { 83 | console.error('Error getting latest docs:', error) 84 | return [] 85 | } 86 | } 87 | 88 | export default function RecentUpdates() { 89 | const [latestDocs, setLatestDocs] = useState([]) 90 | const [loadingLatest, setLoadingLatest] = useState(true) 91 | 92 | useEffect(() => { 93 | const loadLatestDocs = async () => { 94 | const latest = await getLatestDocs() 95 | setLatestDocs(latest) 96 | setLoadingLatest(false) 97 | } 98 | 99 | loadingLatest && loadLatestDocs() 100 | }, [latestDocs]) 101 | 102 | const formatDate = (dateString: string) => { 103 | const date = new Date(dateString) 104 | return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) 105 | } 106 | 107 | return ( 108 |
109 |
110 |

111 | 112 | Recent Updates 113 |

114 |
115 | 116 |
117 | {loadingLatest ? ( 118 |
119 |
120 | Loading... 121 |
122 | ) : ( 123 |
124 |
125 | {latestDocs.map((doc, index) => ( 126 |
127 |
window.open(doc.metadata?.url, "_blank")} 130 | > 131 |
132 | 133 |
134 |
135 |

136 | {doc.content?.title || 'Documentation'} 137 |

138 |
139 | 140 | {doc.indexName} 141 | 142 | 143 | {formatDate(doc.metadata.crawledAt)} 144 | 145 |
146 |
147 |
148 | {index < latestDocs.length - 1 && ( 149 |
150 | )} 151 |
152 | ))} 153 | 154 | {latestDocs.length === 0 && ( 155 |
156 | 157 |

No recent updates in the last week

158 |
159 | )} 160 |
161 |
162 | )} 163 |
164 |
165 | ) 166 | } --------------------------------------------------------------------------------