In the previous tutorial, we completed our Dashboard and Account pages. In this section, we'll build a video summary feature using the AI SDK from Vercel.
- Part 1: Learn Next.js by building a website
- Part 2: Building Out The Hero Section of the homepage
- Part 3: Finishup up the homepage Features Section, TopNavigation and Footer
- Part 4: How to handle login and Authentification in Next.js
- Part 5: Building out the Dashboard page and upload file using NextJS server actions
- Part 6: Get Video Transcript with AI SDK
- Part 7: Strapi CRUD permissions
- Part 8: Search & pagination in Nextjs
- Part 9: Backend deployment to Strapi Cloud
- Part 10: Frontend deployment to Vercel
We'll start by building a SummaryForm
component. Instead of server actions, we'll use Next.js API routes for backend logic.
Learn more about Next.js route handlers.
Creating the Summary Form
First, let's create our summary form component.
Navigate to src/components/forms
and create summary-form.tsx
with this starter code:
1"use client";
2import { useState } from "react";
3import { toast } from "sonner";
4import { cn, extractYouTubeID } from "@/lib/utils";
5
6import { Input } from "@/components/ui/input";
7import { SubmitButton } from "@/components/custom/submit-button";
8
9type ITranscriptResponse = {
10 fullTranscript: string;
11 title?: string;
12 videoId?: string;
13 thumbnailUrl?: string;
14};
15
16interface IErrors {
17 message: string | null;
18 name: string;
19}
20
21const INITIAL_STATE = {
22 message: null,
23 name: "",
24};
25
26export function SummaryForm() {
27 const [loading, setLoading] = useState(false);
28 const [error, setError] = useState<IErrors>(INITIAL_STATE);
29 const [value, setValue] = useState<string>("");
30
31 async function handleFormSubmit(event: React.FormEvent<HTMLFormElement>) {
32 event.preventDefault();
33 setLoading(true);
34
35 const formData = new FormData(event.currentTarget);
36 const videoId = formData.get("videoId") as string;
37 const processedVideoId = extractYouTubeID(videoId);
38
39 if (!processedVideoId) {
40 toast.error("Invalid Youtube Video ID");
41 setLoading(false);
42 setValue("");
43 setError({
44 ...INITIAL_STATE,
45 message: "Invalid Youtube Video ID",
46 name: "Invalid Id",
47 });
48 return;
49 }
50
51 let currentToastId: string | number | undefined;
52
53 try {
54 // Step 1: Get transcript
55 currentToastId = toast.loading("Getting transcript...");
56
57 // Step 2: Generate summary
58 toast.dismiss(currentToastId);
59 currentToastId = toast.loading("Generating summary...");
60
61 // Step 3: Save summary to database
62 toast.dismiss(currentToastId);
63 currentToastId = toast.loading("Saving summary...");
64
65 toast.success("Summary Created and Saved!");
66 setValue("");
67
68 // Redirect to the summary details page
69 } catch (error) {
70 if (currentToastId) toast.dismiss(currentToastId);
71 console.error("Error:", error);
72 toast.error(
73 error instanceof Error ? error.message : "Failed to create summary"
74 );
75 } finally {
76 setLoading(false);
77 }
78 }
79
80 function clearError() {
81 setError(INITIAL_STATE);
82 if (error.message) setValue("");
83 }
84
85 const errorStyles = error.message
86 ? "outline-1 outline outline-red-500 placeholder:text-red-700"
87 : "";
88
89 return (
90 <div className="w-full">
91 <form onSubmit={handleFormSubmit} className="flex gap-2 items-center">
92 <Input
93 name="videoId"
94 placeholder={
95 error.message ? error.message : "Youtube Video ID or URL"
96 }
97 value={value}
98 onChange={(e) => setValue(e.target.value)}
99 onMouseDown={clearError}
100 className={cn(
101 "w-full focus:text-black focus-visible:ring-pink-500",
102 errorStyles
103 )}
104 required
105 />
106
107 <SubmitButton
108 text="Create Summary"
109 loadingText="Creating Summary"
110 className="bg-pink-500"
111 loading={loading}
112 />
113 </form>
114 </div>
115 );
116}
The above code contains a basic form UI and a handleFormSubmit
function, which does not include any of our logic to get the summary yet.
We also use Sonner, one of my favorite toast libraries. You can learn more about it here.
But we are not using it directly; instead, we are using the Chadcn UI component, which you can find here.
npx shadcn@latest add sonner
Once Sonner is installed, implement it in our main layout.tsx
file by adding the following import.
1import { Toaster } from "@/components/ui/sonner";
And adding the code below above our TopNav
.
1<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
2 <Toaster position="bottom-center" />
3 <Header data={globalData.data.header} />
4 {children}
5 <Footer data={globalData.data.footer} />
6</body>
Before adding this component to our top navigation, notice that we expect extractYouTubeID
helper method, let's add the following in our utils.ts
file found in our lib
folder and add the following code:
1export function extractYouTubeID(urlOrID: string): string | null {
2 // Regular expression for YouTube ID format
3 const regExpID = /^[a-zA-Z0-9_-]{11}$/;
4
5 // Check if the input is a YouTube ID
6 if (regExpID.test(urlOrID)) {
7 return urlOrID;
8 }
9
10 // Regular expression for standard YouTube links
11 const regExpStandard = /youtube\.com\/watch\?v=([a-zA-Z0-9_-]+)/;
12
13 // Regular expression for YouTube Shorts links
14 const regExpShorts = /youtube\.com\/shorts\/([a-zA-Z0-9_-]+)/;
15
16 // Check for standard YouTube link
17 const matchStandard = urlOrID.match(regExpStandard);
18 if (matchStandard) {
19 return matchStandard[1];
20 }
21
22 // Check for YouTube Shorts link
23 const matchShorts = urlOrID.match(regExpShorts);
24 if (matchShorts) {
25 return matchShorts[1];
26 }
27
28 // Return null if no match is found
29 return null;
30}
Now we can go ahead and add this form to our top navigation by navigating to the src/components/custom/header.tsx
file and making the following changes.
1// import the form
2import { SummaryForm } from "@/components/forms/summary-form";
3
4// rest of the code
5
6export async function Header({ data }: Readonly<HeaderProps>) {
7 const { logoText, ctaButton } = data;
8 const user = await getUserMeLoader();
9
10 return (
11 <div className={styles.header}>
12 <Logo text={logoText.label} />
13 {user.success && <SummaryForm />}
14 <div className={styles.actions}>
15 {user.success && user.data ? (
16 <LoggedInUser userData={user.data} />
17 ) : (
18 <Link href={ctaButton.href}>
19 <Button>{ctaButton.label}</Button>
20 </Link>
21 )}
22 </div>
23 </div>
24 );
25}
Let's restart our frontend project and see if it shows up.
Now that our basic form is working let's examine how to set up our first API Handler Route in Next.js 15.
How To Create A Route Handler in Next.js 15
We will have the Next.js docs open as a reference.
Let's start by creating a new folder inside our app
directory called api
, a folder called transcript
, and a file called route.ts
. Then, paste in the following code.
1import { NextRequest } from "next/server";
2import { actions } from "@/data/actions";
3import { services } from "@/data/services";
4
5export const maxDuration = 150;
6export const dynamic = "force-dynamic";
7
8export async function POST(req: NextRequest) {
9 const user = await services.auth.getUserMeService();
10 const token = await actions.auth.getAuthTokenAction();
11
12 if (!user.success || !token) {
13 return new Response(
14 JSON.stringify({ data: null, error: "Not authenticated" }),
15 { status: 401 }
16 );
17 }
18
19 const body = await req.json();
20 const videoId = body.videoId;
21
22 try {
23 const transcriptData = await services.summarize.generateTranscript(videoId);
24
25 if (!transcriptData?.fullTranscript) {
26 throw new Error("No transcript data found");
27 }
28
29 return new Response(JSON.stringify({ data: transcriptData, error: null }));
30 } catch (error) {
31 console.error("Error processing request:", error);
32 if (error instanceof Error)
33 return new Response(JSON.stringify({ error: error.message }));
34 return new Response(JSON.stringify({ error: "Unknown error" }));
35 }
36}
Getting Transcript From YouTube
Next, let's create a service to call in our new route handler that will be responsible for generating our video.
We will implement the following library youtubei.js
in our Next.js application, directly.
Let's install it with the following:
yarn add youtubei.js
You can also move this functionality to Strapi via a plugin. You can learn how to do this in the following tutorial.
I like the plugin approach but to keep this tutorial in scope we will just implement it directly.
Navigate to src/data/services
and create a new folder called summary
and inside create a file called generate-transcript.ts
.
And add the following code:
1import {
2 TranscriptData,
3 TranscriptSegment,
4 YouTubeTranscriptSegment,
5 YouTubeAPIVideoInfo,
6} from "./types";
7
8const processTranscriptSegments = (
9 segments: YouTubeTranscriptSegment[]
10): TranscriptSegment[] => {
11 return segments.map((segment) => ({
12 text: segment.snippet.text,
13 start: Number(segment.start_ms),
14 end: Number(segment.end_ms),
15 duration: Number(segment.end_ms) - Number(segment.start_ms),
16 }));
17};
18
19const cleanImageUrl = (url: string): string => url.split("?")[0];
20
21const validateIdentifier = (identifier: string): void => {
22 if (!identifier || typeof identifier !== "string") {
23 throw new Error("Invalid YouTube video identifier");
24 }
25};
26
27const extractBasicInfo = (info: YouTubeAPIVideoInfo) => {
28 const { title, id: videoId, thumbnail } = info.basic_info;
29 const thumbnailUrl = thumbnail?.[0]?.url;
30
31 return {
32 title: title || "Untitled Video",
33 videoId,
34 thumbnailUrl: thumbnailUrl ? cleanImageUrl(thumbnailUrl) : undefined,
35 };
36};
37
38const getTranscriptSegments = async (
39 info: YouTubeAPIVideoInfo
40): Promise<YouTubeTranscriptSegment[]> => {
41 const transcriptData = await info.getTranscript();
42
43 if (!transcriptData?.transcript?.content?.body?.initial_segments) {
44 throw new Error("No transcript available for this video");
45 }
46
47 return transcriptData.transcript.content.body.initial_segments;
48};
49
50export const generateTranscript = async (
51 identifier: string
52): Promise<TranscriptData> => {
53 console.log(identifier);
54 try {
55 const { Innertube } = await import("youtubei.js");
56 const youtube = await Innertube.create({
57 lang: "en",
58 location: "US",
59 retrieve_player: false,
60 });
61
62 console.log("IDENTIFIER", identifier, "VS", "LCYBVpSB0Wo");
63
64 validateIdentifier(identifier);
65
66 const info = await youtube.getInfo(identifier);
67
68 console.log("INFO:", info);
69 if (!info) {
70 throw new Error("No video information found");
71 }
72
73 const { title, videoId, thumbnailUrl } = extractBasicInfo(
74 info as YouTubeAPIVideoInfo
75 );
76 const segments = await getTranscriptSegments(info as YouTubeAPIVideoInfo);
77 const transcriptWithTimeCodes = processTranscriptSegments(segments);
78 const fullTranscript = segments
79 .map((segment) => segment.snippet.text)
80 .join(" ");
81
82 return {
83 title,
84 videoId,
85 thumbnailUrl,
86 fullTranscript,
87 transcriptWithTimeCodes,
88 };
89 } catch (error) {
90 console.error("Error fetching transcript:", error);
91 throw new Error(
92 error instanceof Error ? error.message : "Failed to fetch transcript"
93 );
94 }
95};
Now let's create our types
file and add the following types.
1export interface StrapiConfig {
2 baseUrl: string;
3 apiToken: string;
4 path: string;
5}
6
7export interface TranscriptSegment {
8 text: string;
9 start: number;
10 end: number;
11 duration: number;
12}
13
14export interface TranscriptData {
15 title: string | undefined;
16 videoId: string | undefined;
17 thumbnailUrl: string | undefined;
18 fullTranscript: string | undefined;
19 transcriptWithTimeCodes?: TranscriptSegment[];
20}
21
22// Add proper types
23export interface SummaryData {
24 fullTranscript: string;
25 title: string;
26 thumbnailUrl: string;
27 transcriptWithTimeCodes: TranscriptSegment[];
28}
29
30export interface YouTubeTranscriptSegment {
31 snippet: {
32 text: string;
33 };
34 start_ms: string;
35 end_ms: string;
36}
37
38export interface YouTubeThumbnail {
39 url: string;
40 width?: number;
41 height?: number;
42}
43
44export interface YouTubeBasicInfo {
45 title: string | undefined;
46 id: string;
47 thumbnail?: YouTubeThumbnail[];
48}
49
50export interface YouTubeTranscriptContent {
51 transcript: {
52 content: {
53 body: {
54 initial_segments: YouTubeTranscriptSegment[];
55 };
56 };
57 };
58}
59
60// Minimal interface for the properties we actually use from the YouTube API
61export interface YouTubeAPIVideoInfo {
62 basic_info: {
63 title?: string;
64 id: string;
65 thumbnail?: Array<{
66 url: string;
67 width?: number;
68 height?: number;
69 }>;
70 };
71 getTranscript(): Promise<YouTubeTranscriptContent>;
72}
And finally let export our summary
folder, let's create index.ts
file and add the following code:
1import { generateTranscript } from "./generate-transcript";
2
3export { generateTranscript };
And finally let's add this to our root service index.ts
file to export our summary service.
1import { generateTranscript } from "./summary";
2
3summarize: {
4 generateTranscript,
5},
The full file should look like the following:
1import {
2 registerUserService,
3 loginUserService,
4 getUserMeService,
5} from "./auth";
6
7import { updateProfileService, updateProfileImageService } from "./profile";
8import { fileUploadService, fileDeleteService } from "./file";
9
10import { generateTranscript } from "./summary";
11
12export const services = {
13 auth: {
14 registerUserService,
15 loginUserService,
16 getUserMeService,
17 },
18 profile: {
19 updateProfileService,
20 updateProfileImageService,
21 },
22 file: {
23 fileUploadService,
24 fileDeleteService,
25 },
26 summarize: {
27 generateTranscript,
28 },
29};
Now, let's update our form to incorperate our newly created route.
In the handle submit, lets add the following inside our try catch under Step 1 comment:
1const transcriptResponse = await api.post<
2 ITranscriptResponse,
3 { videoId: string }
4>("/api/transcript", { videoId: processedVideoId });
5
6if (!transcriptResponse.success) {
7 toast.dismiss(currentToastId);
8 toast.error(transcriptResponse.error?.message);
9 return;
10}
11
12const fullTranscript = !transcriptResponse.data?.fullTranscript;
13
14if (!fullTranscript) {
15 toast.dismiss(currentToastId);
16 toast.error("No transcript data found");
17 return;
18}
19
20console.log(fullTranscript);
Don't forget to import our api
util at the top:
1import { api } from "@/data/data-api";
The complete summary-form.tsx
file should look like the following.
1"use client";
2import { useState } from "react";
3import { toast } from "sonner";
4import { cn, extractYouTubeID } from "@/lib/utils";
5import { api } from "@/data/data-api";
6
7import { Input } from "@/components/ui/input";
8import { SubmitButton } from "@/components/custom/submit-button";
9
10type ITranscriptResponse = {
11 fullTranscript: string;
12 title?: string;
13 videoId?: string;
14 thumbnailUrl?: string;
15};
16
17interface IErrors {
18 message: string | null;
19 name: string;
20}
21
22const INITIAL_STATE = {
23 message: null,
24 name: "",
25};
26
27export function SummaryForm() {
28 const [loading, setLoading] = useState(false);
29 const [error, setError] = useState<IErrors>(INITIAL_STATE);
30 const [value, setValue] = useState<string>("");
31
32 async function handleFormSubmit(event: React.FormEvent<HTMLFormElement>) {
33 event.preventDefault();
34 setLoading(true);
35
36 const formData = new FormData(event.currentTarget);
37 const videoId = formData.get("videoId") as string;
38 const processedVideoId = extractYouTubeID(videoId);
39
40 if (!processedVideoId) {
41 toast.error("Invalid Youtube Video ID");
42 setLoading(false);
43 setValue("");
44 setError({
45 ...INITIAL_STATE,
46 message: "Invalid Youtube Video ID",
47 name: "Invalid Id",
48 });
49 return;
50 }
51
52 let currentToastId: string | number | undefined;
53
54 try {
55 // Step 1: Get transcript
56 currentToastId = toast.loading("Getting transcript...");
57
58 const transcriptResponse = await api.post<
59 ITranscriptResponse,
60 { videoId: string }
61 >("/api/transcript", { videoId: processedVideoId });
62
63 if (!transcriptResponse.success) {
64 toast.dismiss(currentToastId);
65 toast.error(transcriptResponse.error?.message);
66 return;
67 }
68
69 const fullTranscript = !transcriptResponse.data?.fullTranscript;
70
71 if (!fullTranscript) {
72 toast.dismiss(currentToastId);
73 toast.error("No transcript data found");
74 return;
75 }
76
77 console.log(fullTranscript);
78
79 // Step 2: Generate summary
80 toast.dismiss(currentToastId);
81 currentToastId = toast.loading("Generating summary...");
82
83 // Step 3: Save summary to database
84 toast.dismiss(currentToastId);
85 currentToastId = toast.loading("Saving summary...");
86
87 toast.success("Summary Created and Saved!");
88 setValue("");
89
90 // Redirect to the summary details page
91 } catch (error) {
92 if (currentToastId) toast.dismiss(currentToastId);
93 console.error("Error:", error);
94 toast.error(
95 error instanceof Error ? error.message : "Failed to create summary"
96 );
97 } finally {
98 setLoading(false);
99 }
100 }
101
102 function clearError() {
103 setError(INITIAL_STATE);
104 if (error.message) setValue("");
105 }
106
107 const errorStyles = error.message
108 ? "outline-1 outline outline-red-500 placeholder:text-red-700"
109 : "";
110
111 return (
112 <div className="w-full flex-1 mx-4">
113 <form onSubmit={handleFormSubmit} className="flex gap-2 items-center">
114 <Input
115 name="videoId"
116 placeholder={
117 error.message ? error.message : "Youtube Video ID or URL"
118 }
119 value={value}
120 onChange={(e) => setValue(e.target.value)}
121 onMouseDown={clearError}
122 className={cn(
123 "w-full focus:text-black focus-visible:ring-pink-500",
124 errorStyles
125 )}
126 required
127 />
128
129 <SubmitButton
130 text="Create Summary"
131 loadingText="Creating Summary"
132 className="bg-pink-500"
133 loading={loading}
134 />
135 </form>
136 </div>
137 );
138}
Now, let's test our front end to see if our transcript service is working as expected.
Once we submit our form, you should see the response in the console.
Excellent. Now that we have our transcript, we can use it to prepare our summary.
Summarize Video with AI SDK with OPEN AI
We will leverage AI SDK to summarize our video.
It is an amazing library from Vercel, and makes it easy to interact with our OPEN AI LLM.
Let's first start by installing the package with the following.
And then we will go through the process of creating our summarize service.
We can install the AI SDK with the following:
yarn add ai
and
yarn add @ai-sdk/openai
Since we will be using the Open AI model.
note: You will need to add your Open AI API key in the .env file.
1OPENAI_API_KEY = your_api_key_here;
Now, let's create our service to generate our summary using AI SDK.
Navigate to our summary
folder and create the following file generate-summary.ts
and paste in the following code:
1import { openai } from "@ai-sdk/openai";
2import { generateText } from "ai";
3
4export async function generateSummary(content: string, template?: string) {
5 const systemPrompt =
6 template ||
7 `
8 You are a helpful assistant that creates concise and informative summaries of YouTube video transcripts.
9 Please summarize the following transcript, highlighting the key points and main ideas.
10 Keep the summary clear, well-structured, and easy to understand.
11 `;
12
13 try {
14 const { text } = await generateText({
15 model: openai(process.env.OPENAI_MODEL ?? "gpt-4o-mini"),
16 system: systemPrompt,
17 prompt: `Please summarize this transcript:\n\n${content}`,
18 temperature: process.env.OPENAI_TEMPERATURE
19 ? parseFloat(process.env.OPENAI_TEMPERATURE)
20 : 0.7,
21 maxOutputTokens: process.env.OPENAI_MAX_TOKENS
22 ? parseInt(process.env.OPENAI_MAX_TOKENS)
23 : 4000,
24 });
25
26 return text;
27 } catch (error) {
28 console.error("Error generating summary:", error);
29
30 if (error instanceof Error) {
31 throw new Error(`Failed to generate summary: ${error.message}`);
32 }
33
34 throw new Error("Failed to generate summary");
35 }
36}
Nice, now let's export our service from the summary/index.ts
file:
1import { generateTranscript } from "./generate-transcript";
2import { generateSummary } from "./generate-summary";
3
4export { generateTranscript, generateSummary };
And export it from the root service/index.ts
file:
1import {
2 registerUserService,
3 loginUserService,
4 getUserMeService,
5} from "./auth";
6
7import { updateProfileService, updateProfileImageService } from "./profile";
8import { fileUploadService, fileDeleteService } from "./file";
9
10import { generateTranscript, generateSummary } from "./summary";
11
12export const services = {
13 auth: {
14 registerUserService,
15 loginUserService,
16 getUserMeService,
17 },
18 profile: {
19 updateProfileService,
20 updateProfileImageService,
21 },
22 file: {
23 fileUploadService,
24 fileDeleteService,
25 },
26 summarize: {
27 generateTranscript,
28 generateSummary,
29 },
30};
Now let's go pack to our summary-form.tsx
and add the following changes in the form submit handler bellow the Step 2 comment:
1const summaryResponse = await api.post<string, { fullTranscript: string }>(
2 "/api/summarize",
3 { fullTranscript: fullTranscript },
4 { timeoutMs: 120000 }
5);
6
7if (!summaryResponse.success) {
8 toast.dismiss(currentToastId);
9 toast.error(summaryResponse.error?.message);
10 return;
11}
12
13const summaryData = summaryResponse.data;
14
15if (!summaryData) {
16 toast.dismiss(currentToastId);
17 toast.error("No summary generated");
18 return;
19}
20
21console.log(summaryData);
Notice we are making a POST request to /api/summarize
endpoint, we did not yet create one, but it will follow a simular pattern of creating next.js routes.
Inside of our app/api
folder, create a new folder called summarize
with a file called route.ts
and add the following code:
1import { NextRequest } from "next/server";
2import { actions } from "@/data/actions";
3import { services } from "@/data/services";
4
5export const maxDuration = 150;
6export const dynamic = "force-dynamic";
7
8const TEMPLATE = `
9You are an expert content analyst and copywriter. Create a comprehensive summary following this exact structure:
10
11## Quick Overview
12Start with a 2-3 sentence description of what this content covers.
13
14## Key Topics Summary
15Summarize the content using 5 main topics. Write in a conversational, first-person tone as if explaining to a friend.
16
17## Key Points & Benefits
18List the most important points and practical benefits viewers will gain.
19
20## Detailed Summary
21Write a complete Summary including:
22- Engaging introduction paragraph
23- Timestamped sections (if applicable)
24- Key takeaways section
25- Call-to-action
26
27---
28Format your response using clear markdown headers and bullet points. Keep language natural and accessible throughout.
29`.trim();
30
31export async function POST(req: NextRequest) {
32 const user = await services.auth.getUserMeService();
33 const token = await actions.auth.getAuthTokenAction();
34
35 if (!user.success || !token) {
36 return new Response(
37 JSON.stringify({ data: null, error: "Not authenticated" }),
38 { status: 401 }
39 );
40 }
41
42 console.log("USER CREDITS: ", user.data?.credits);
43
44 if (!user.data || (user.data.credits || 0) < 1) {
45 return new Response(
46 JSON.stringify({
47 data: null,
48 error: "Insufficient credits",
49 }),
50 { status: 402 }
51 );
52 }
53
54 const body = await req.json();
55 const { fullTranscript } = body;
56
57 if (!fullTranscript) {
58 return new Response(JSON.stringify({ error: "No transcript provided" }), {
59 status: 400,
60 });
61 }
62
63 try {
64 const summary = await services.summarize.generateSummary(
65 fullTranscript,
66 TEMPLATE
67 );
68
69 return new Response(JSON.stringify({ data: summary, error: null }));
70 } catch (error) {
71 console.error("Error processing request:", error);
72 if (error instanceof Error)
73 return new Response(JSON.stringify({ error: error.message }));
74 return new Response(JSON.stringify({ error: "Error generating summary." }));
75 }
76}
This route is gong to use our generateSummaryService
also you may notice this code snippet where we check if user has enough credits.
1if (!user.data || (user.data.credits || 0) < 1) {
2 return new Response(
3 JSON.stringify({
4 error: {
5 status: 402,
6 name: "InsufficientCredits",
7 message: "Insufficient credits to generate summary",
8 },
9 }),
10 { status: 402 }
11 );
12}
Now, let's check if we are getting the summary?
Great! The insufficient credits error message works correctly. When we update the credits, we can successfully generate our summary.
Now that we know we are getting our summary, let's take a look how we can save our summaries to Strapi.
Saving Our Summary To Strapi
First, create a new collection-type
in Strapi admin to save our summary.
Navigate to the content builder page and create a new collection named Summary
with the following fields.
Let's add the following fields.
Name | Field | Type |
---|---|---|
videoId | Text | Short Text |
title | Text | Short Text |
content | Rich Text | Markdown |
userId | Text | Short Text |
You can make a relations between the Summary and User collection types. But in this case we will only use the userId
field to store the documentId
of the user who created the summary.
This way we don't need to expose the user
api which is something we would have to do if we were to make a relations between the Summary and User collection types.
So whenever we want to find all summaries for a user, we can simply query the Summary collection type and filter by the userId
field.
Here is what the final fields will look like.
Now, navigate to Setting
and add the following permissions.
Now that we have our Summary collection-type
, let's create a service to handle saving our summary to Strapi.
Navigate to our summary
folder in service and create the following file save-summary.ts
and add the following code:
1import qs from "qs";
2import { getStrapiURL } from "@/lib/utils";
3import type { TStrapiResponse, TSummary } from "@/types";
4import { api } from "@/data/data-api";
5
6const baseUrl = getStrapiURL();
7
8export async function saveSummaryService(
9 summaryData: Partial<TSummary>
10): Promise<TStrapiResponse<TSummary>> {
11 const query = qs.stringify({
12 populate: "*",
13 });
14
15 const url = new URL("/api/summaries", baseUrl);
16 url.search = query;
17
18 // Strapi expects data to be wrapped in a 'data' object
19 const payload = { data: summaryData };
20 const result = await api.post<TSummary, typeof payload>(url.href, payload);
21
22 console.log("######### actual save summary response");
23 console.dir(result, { depth: null });
24
25 return result;
26}
Make sure to export it from the summary/index.ts
file:
1import { generateTranscript } from "./generate-transcript";
2import { generateSummary } from "./generate-summary";
3import { saveSummaryService } from "./save-summary";
4
5export { generateTranscript, generateSummary, saveSummaryService };
And export it from the root index.ts
file found in the services
folder:
1import {
2 registerUserService,
3 loginUserService,
4 getUserMeService,
5} from "./auth";
6
7import { updateProfileService, updateProfileImageService } from "./profile";
8import { fileUploadService, fileDeleteService } from "./file";
9
10import {
11 generateTranscript,
12 generateSummary,
13 saveSummaryService,
14} from "./summary";
15
16export const services = {
17 auth: {
18 registerUserService,
19 loginUserService,
20 getUserMeService,
21 },
22 profile: {
23 updateProfileService,
24 updateProfileImageService,
25 },
26 file: {
27 fileUploadService,
28 fileDeleteService,
29 },
30 summarize: {
31 generateTranscript,
32 generateSummary,
33 saveSummaryService,
34 },
35};
Now that we have our saveSummaryService
, let's use it in our handleFormSubmit,
found in our form named summary-form.tsx
.
First, let's import our newly created service.
Update the handleFormSubmit
with the following code after the Step 3 comment:
1const saveResponse = await services.summarize.saveSummaryService({
2 title: transcriptResponse.data?.title || `Summary for ${processedVideoId}`,
3 content: summaryResponse.data,
4 videoId: processedVideoId,
5});
6
7if (!saveResponse.success) {
8 toast.dismiss(currentToastId);
9 toast.error(saveResponse.error?.message);
10 return;
11}
12
13console.log("SAVE RESPONSE:", saveResponse);
14toast.dismiss(currentToastId);
15currentToastId = undefined;
16toast.success("Summary Created and Saved!");
17setValue("");
18
19// Redirect to the summary details page
20router.push("/dashboard/summaries/" + saveResponse.data?.documentId);
Notice we are using router.push()
make sure to import it from useRouter
at the top.
1import { useRouter } from "next/navigation";
and define it in the component:
1const router = useRouter();
The final code should look like the following:
1"use client";
2import { useState } from "react";
3import { toast } from "sonner";
4import { cn, extractYouTubeID } from "@/lib/utils";
5import { api } from "@/data/data-api";
6import { useRouter } from "next/navigation";
7import { services } from "@/data/services";
8
9import { Input } from "@/components/ui/input";
10import { SubmitButton } from "@/components/custom/submit-button";
11
12type ITranscriptResponse = {
13 fullTranscript: string;
14 title?: string;
15 videoId?: string;
16 thumbnailUrl?: string;
17};
18
19interface IErrors {
20 message: string | null;
21 name: string;
22}
23
24const INITIAL_STATE = {
25 message: null,
26 name: "",
27};
28
29export function SummaryForm() {
30 const router = useRouter();
31 const [loading, setLoading] = useState(false);
32 const [error, setError] = useState<IErrors>(INITIAL_STATE);
33 const [value, setValue] = useState<string>("");
34
35 async function handleFormSubmit(event: React.FormEvent<HTMLFormElement>) {
36 event.preventDefault();
37 setLoading(true);
38
39 const formData = new FormData(event.currentTarget);
40 const videoId = formData.get("videoId") as string;
41 const processedVideoId = extractYouTubeID(videoId);
42
43 if (!processedVideoId) {
44 toast.error("Invalid Youtube Video ID");
45 setLoading(false);
46 setValue("");
47 setError({
48 ...INITIAL_STATE,
49 message: "Invalid Youtube Video ID",
50 name: "Invalid Id",
51 });
52 return;
53 }
54
55 let currentToastId: string | number | undefined;
56
57 try {
58 // Step 1: Get transcript
59 currentToastId = toast.loading("Getting transcript...");
60
61 const transcriptResponse = await api.post<
62 ITranscriptResponse,
63 { videoId: string }
64 >("/api/transcript", { videoId: processedVideoId });
65
66 if (!transcriptResponse.success) {
67 toast.dismiss(currentToastId);
68 toast.error(transcriptResponse.error?.message);
69 return;
70 }
71
72 const fullTranscript = transcriptResponse.data?.fullTranscript;
73
74 if (!fullTranscript) {
75 toast.dismiss(currentToastId);
76 toast.error("No transcript data found");
77 return;
78 }
79
80 // Step 2: Generate summary
81 toast.dismiss(currentToastId);
82 currentToastId = toast.loading("Generating summary...");
83
84 const summaryResponse = await api.post<
85 string,
86 { fullTranscript: string }
87 >(
88 "/api/summarize",
89 { fullTranscript: fullTranscript },
90 { timeoutMs: 120000 }
91 );
92
93 if (!summaryResponse.success) {
94 toast.dismiss(currentToastId);
95 toast.error(summaryResponse.error?.message);
96 return;
97 }
98
99 const summaryData = summaryResponse.data;
100
101 if (!summaryData) {
102 toast.dismiss(currentToastId);
103 toast.error("No summary generated");
104 return;
105 }
106
107 console.log(summaryData);
108
109 // Step 3: Save summary to database
110 toast.dismiss(currentToastId);
111 currentToastId = toast.loading("Saving summary...");
112
113 const saveResponse = await services.summarize.saveSummaryService({
114 title:
115 transcriptResponse.data?.title || `Summary for ${processedVideoId}`,
116 content: summaryResponse.data,
117 videoId: processedVideoId,
118 });
119
120 if (!saveResponse.success) {
121 toast.dismiss(currentToastId);
122 toast.error(saveResponse.error?.message);
123 return;
124 }
125
126 console.log("SAVE RESPONSE:", saveResponse);
127 toast.dismiss(currentToastId);
128 currentToastId = undefined;
129 toast.success("Summary Created and Saved!");
130 setValue("");
131
132 // Redirect to the summary details page
133 router.push("/dashboard/summaries/" + saveResponse.data?.documentId);
134
135 toast.success("Summary Created and Saved!");
136 setValue("");
137
138 // Redirect to the summary details page
139 } catch (error) {
140 if (currentToastId) toast.dismiss(currentToastId);
141 console.error("Error:", error);
142 toast.error(
143 error instanceof Error ? error.message : "Failed to create summary"
144 );
145 } finally {
146 setLoading(false);
147 }
148 }
149
150 function clearError() {
151 setError(INITIAL_STATE);
152 if (error.message) setValue("");
153 }
154
155 const errorStyles = error.message
156 ? "outline-1 outline outline-red-500 placeholder:text-red-700"
157 : "";
158
159 return (
160 <div className="w-full flex-1 mx-4">
161 <form onSubmit={handleFormSubmit} className="flex gap-2 items-center">
162 <Input
163 name="videoId"
164 placeholder={
165 error.message ? error.message : "Youtube Video ID or URL"
166 }
167 value={value}
168 onChange={(e) => setValue(e.target.value)}
169 onMouseDown={clearError}
170 className={cn(
171 "w-full focus:text-black focus-visible:ring-pink-500",
172 errorStyles
173 )}
174 required
175 />
176
177 <SubmitButton
178 text="Create Summary"
179 loadingText="Creating Summary"
180 className="bg-pink-500"
181 loading={loading}
182 />
183 </form>
184 </div>
185 );
186}
The above code will be responsible for saving our data into Strapi.
Let's do a quick test and see if it works. We should be redirected to our summaries
route, which we have yet to create, so we will get our not found page. This is okay, and we will fix it soon.
Nice, we are able to save our summary to our Strapi database.
You will notice that we are not setting our user or deducting one credit on creation. We will do this in Strapi by creating custom middleware. But first, let's finish all of our Next.js UI.
Create Summary Page Card View
Let's navigate to our dashboard
folder. Inside, create another folder named summaries
with a page.tsx
file and paste it into the following code:
1import { loaders } from "@/data/loaders";
2import { SummariesGrid } from "@/components/custom/summaries-grid";
3import { validateApiResponse } from "@/lib/error-handler";
4
5export default async function SummariesRoute() {
6 const data = await loaders.getSummaries();
7 const summaries = validateApiResponse(data, "summaries");
8
9 return (
10 <div className="flex flex-col min-h-[calc(100vh-80px)] p-4 gap-6">
11 <SummariesGrid summaries={summaries} className="flex-grow" />
12 </div>
13 );
14}
Before this component works, we must create the getSummaries
function to load our data.
Let's navigate to our loaders.ts
file and make the following changes.
First, import the actions and our TSummary type:
1import type {
2 // rest of type
3 TSummary,
4} from "@/types";
5import { actions } from "@/data/actions";
Next, lets create the getSummaries
loader function with the following code.
1async function getSummaries(): Promise<TStrapiResponse<TSummary[]>> {
2 const authToken = await actions.auth.getAuthTokenAction();
3 if (!authToken) throw new Error("You are not authorized");
4
5 const query = qs.stringify({
6 sort: ["createdAt:desc"],
7 });
8
9 const url = new URL("/api/summaries", baseUrl);
10 url.search = query;
11 return api.get<TSummary[]>(url.href, { authToken });
12}
Don't forget to export it from the bottom of the file:
1export const loaders = {
2 getHomePageData,
3 getGlobalData,
4 getMetaData,
5 getSummaries,
6};
Now that we have our loader, back in out summary-form.tsx
we are using our SummaryGrid component. Let's create it now.
In the components/custom
folder create a new file called summaries-grid
and add the following code:
1import Link from "next/link";
2import { TSummary } from "@/types";
3import Markdown from "react-markdown";
4import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5import { cn } from "@/lib/utils";
6
7interface ILinkCardProps {
8 summary: TSummary;
9}
10
11const styles = {
12 card: "relative hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200",
13 cardHeader: "pb-3",
14 cardTitle: "text-lg font-semibold text-pink-600 leading-tight line-clamp-2",
15 cardContent: "pt-0",
16 markdown: `prose prose-sm max-w-none overflow-hidden
17 prose-headings:text-gray-900 prose-headings:font-medium prose-headings:text-base prose-headings:mb-1 prose-headings:mt-0 prose-headings:leading-tight
18 prose-p:text-gray-600 prose-p:leading-relaxed prose-p:text-sm prose-p:mb-1 prose-p:mt-0
19 prose-a:text-pink-500 prose-a:no-underline hover:prose-a:underline
20 prose-strong:text-gray-900 prose-strong:font-medium
21 prose-ul:list-disc prose-ul:pl-4 prose-ul:text-sm prose-ul:mb-1 prose-ul:mt-0
22 prose-ol:list-decimal prose-ol:pl-4 prose-ol:text-sm prose-ol:mb-1 prose-ol:mt-0
23 prose-li:text-gray-600 prose-li:text-sm prose-li:mb-0
24 [&>*:nth-child(n+4)]:hidden`,
25 grid: "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6",
26};
27
28function LinkCard({ summary }: Readonly<ILinkCardProps>) {
29 const { documentId, title, content } = summary;
30 return (
31 <Link href={`/dashboard/summaries/${documentId}`}>
32 <Card className={styles.card}>
33 <CardHeader className={styles.cardHeader}>
34 <CardTitle className={styles.cardTitle}>
35 {title || "Video Summary"}
36 </CardTitle>
37 </CardHeader>
38 <CardContent className={styles.cardContent}>
39 <div className={styles.markdown}>
40 <Markdown>{content.slice(0, 150)}</Markdown>
41 </div>
42 <p className="text-pink-500 font-medium text-xs mt-3">Read more →</p>
43 </CardContent>
44 </Card>
45 </Link>
46 );
47}
48
49interface ISummariesGridProps {
50 summaries: TSummary[];
51 className?: string;
52}
53
54export function SummariesGrid({ summaries, className }: ISummariesGridProps) {
55 return (
56 <div className={cn(styles.grid, className)}>
57 {summaries.map((item: TSummary) => (
58 <LinkCard key={item.documentId} summary={item} />
59 ))}
60 </div>
61 );
62}
Notice it is using react-markdown
package, let's install it:
yarn add react-markdown
Perfect! Now that everything is connected, when you navigate to the summaries page, you'll see your first summary.
Now that we have our Summaries view working, let's create a detail view that displays both the summary and video.
Creating Dynamic Routes In Next.js
Let's create a dynamic route. Dynamic routes allow you to add custom parameters to your URLs - perfect for individual summary pages.
Learn more about dynamic routes in the Next.js docs.
Create a new folder called [documentId]
inside the summaries
folder with a page.tsx
file:
1import { Params } from "@/types";
2// import { loaders } from "@/data/loaders";
3// import { extractYouTubeID } from "@/lib/utils";
4// import { validateApiResponse } from "@/lib/error-handler";
5import { notFound } from "next/navigation";
6// import { YouTubePlayer } from "@/components/custom/youtube-player";
7// import { SummaryUpdateForm } from "@/components/forms/summary-update-form"
8
9interface IPageProps {
10 params: Params;
11}
12
13export default async function SummarySingleRoute({ params }: IPageProps) {
14 const resolvedParams = await params;
15 const documentId = resolvedParams?.documentId;
16
17 if (!documentId) notFound();
18
19 // const data = await loaders.getSummaryByDocumentId(documentId);
20 // const summary = validateApiResponse(data, "summary");
21 // const videoId = extractYouTubeID(summary.videoId);
22
23 return (
24 <div className="h-screen overflow-hidden">
25 <div className="h-full grid gap-4 grid-cols-5 p-4">
26 <div className="col-span-3 h-full">
27 <pre>Document Id: {documentId}</pre>
28 {/* <SummaryUpdateForm summary={summary}/> */}
29 </div>
30 <div className="col-span-2">
31 <div>
32 {/* {videoId ? (
33 <YouTubePlayer videoId={videoId} />
34 ) : (
35 <p>Invalid video URL</p>
36 )}
37 <h1 className="text-2xl font-bold mt-4">{summary.title}</h1> */}
38 </div>
39 </div>
40 </div>
41 </div>
42 );
43}
Before implementing our loader and other components, let's test the dynamic page.
When you click on a summary card, you'll be redirected to the single summary view showing the documentId.
Now that we know our pages work, let's create the loaders to get the appropriate data.
Fetching And Displaying Our Single Video and Summary
Let's start by navigating our loaders.ts
file and adding the following functions.
1async function getSummaryByDocumentId(
2 documentId: string
3): Promise<TStrapiResponse<TSummary>> {
4 const authToken = await actions.auth.getAuthTokenAction();
5 if (!authToken) throw new Error("You are not authorized");
6
7 const path = `/api/summaries/${documentId}`;
8 const url = new URL(path, baseUrl);
9
10 return api.get<TSummary>(url.href, { authToken });
11}
Don't forget to export it:
1export const loaders = {
2 getHomePageData,
3 getGlobalData,
4 getMetaData,
5 getSummaries,
6 getSummaryByDocumentId,
7};
To make sure that we are loading our data, let's go back to our summaries/[documentId]/page.tsx
and uncomment the loader and the validation and add a console for our content:
1import { Params } from "@/types";
2import { loaders } from "@/data/loaders";
3import { extractYouTubeID } from "@/lib/utils";
4import { validateApiResponse } from "@/lib/error-handler";
5import { notFound } from "next/navigation";
6// import { YouTubePlayer } from "@/components/custom/youtube-player";
7// import { SummaryUpdateForm } from "@/components/forms/summary-update-form"
8
9interface IPageProps {
10 params: Params;
11}
12
13export default async function SummarySingleRoute({ params }: IPageProps) {
14 const resolvedParams = await params;
15 const documentId = resolvedParams?.documentId;
16
17 if (!documentId) notFound();
18
19 const data = await loaders.getSummaryByDocumentId(documentId);
20 const summary = validateApiResponse(data, "summary");
21 const videoId = extractYouTubeID(summary.videoId);
22
23 return (
24 <div className="h-screen overflow-hidden">
25 <div className="h-full grid gap-4 grid-cols-5 p-4">
26 <div className="col-span-3 h-full">
27 <pre>Document Id: {documentId}</pre>
28 <pre>{JSON.stringify(summary)}</pre>
29 {/* <SummaryUpdateForm summary={summary}/> */}
30 </div>
31 <div className="col-span-2">
32 <div>
33 {/* {videoId ? (
34 <YouTubePlayer videoId={videoId} />
35 ) : (
36 <p>Invalid video URL</p>
37 )}
38 <h1 className="text-2xl font-bold mt-4">{summary.title}</h1> */}
39 </div>
40 </div>
41 </div>
42 </div>
43 );
44}
If you navigate to your summary, you should see the following.
Now that we know we are getting our data, let's create the last two components, one for our video player, and the other to display our content.
Create a Simple YouTube Player
There are many packages online that we can use, but in this tutorial we will take the simplest approach and use an iframe
.
Inside of our components
folder under custom
let's create the the following file youtube-player.tsx
with this code:
1"use client";
2import { useState } from "react";
3import { Skeleton } from "@/components/ui/skeleton";
4import { Play } from "lucide-react";
5
6interface IYouTubePlayerProps {
7 videoId: string;
8}
9
10const styles = {
11 container: "relative w-full h-[315px] rounded-lg overflow-hidden",
12 skeletonWrapper: "absolute inset-0 w-full h-full",
13 skeleton: "w-full h-full animate-pulse",
14 iconContainer: "absolute inset-0 flex items-center justify-center",
15 playIcon: "w-16 h-16 text-gray-400 animate-bounce",
16 iframe: "rounded-lg",
17};
18
19export function YouTubePlayer({ videoId }: IYouTubePlayerProps) {
20 const [isLoaded, setIsLoaded] = useState(false);
21
22 return (
23 <div className={styles.container}>
24 {!isLoaded && (
25 <div className={styles.skeletonWrapper}>
26 <Skeleton className={styles.skeleton} />
27 <div className={styles.iconContainer}>
28 <Play className={styles.playIcon} fill="currentColor" />
29 </div>
30 </div>
31 )}
32 <iframe
33 width="100%"
34 height="315"
35 src={`https://www.youtube.com/embed/${videoId}`}
36 title="YouTube video player"
37 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
38 allowFullScreen
39 className={styles.iframe}
40 onLoad={() => setIsLoaded(true)}
41 style={{ display: isLoaded ? "block" : "none" }}
42 />
43 </div>
44 );
45}
Now let's navigate back to our single summary page and uncomment th youtube player import and code snippet.
The code should look like the following:
1"use client";
2import { useState } from "react";
3import { Skeleton } from "@/components/ui/skeleton";
4import { Play } from "lucide-react";
5
6interface IYouTubePlayerProps {
7 videoId: string;
8}
9
10const styles = {
11 container: "relative w-full h-[315px] rounded-lg overflow-hidden",
12 skeletonWrapper: "absolute inset-0 w-full h-full",
13 skeleton: "w-full h-full animate-pulse",
14 iconContainer: "absolute inset-0 flex items-center justify-center",
15 playIcon: "w-16 h-16 text-gray-400 animate-bounce",
16 iframe: "rounded-lg",
17};
18
19export function YouTubePlayer({ videoId }: IYouTubePlayerProps) {
20 const [isLoaded, setIsLoaded] = useState(false);
21
22 return (
23 <div className={styles.container}>
24 {!isLoaded && (
25 <div className={styles.skeletonWrapper}>
26 <Skeleton className={styles.skeleton} />
27 <div className={styles.iconContainer}>
28 <Play className={styles.playIcon} fill="currentColor" />
29 </div>
30 </div>
31 )}
32 <iframe
33 width="100%"
34 height="315"
35 src={`https://www.youtube.com/embed/${videoId}`}
36 title="YouTube video player"
37 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
38 allowFullScreen
39 className={styles.iframe}
40 onLoad={() => setIsLoaded(true)}
41 style={{ display: isLoaded ? "block" : "none" }}
42 />
43 </div>
44 );
45}
Now when we navigate to our single summary view we should see the following.
Now, let's display our summary.
How To Create Markdown Editor
We are going to create a nice Markdown editor to display our content. We will use a popular open source editor called MDX Editor you can learn more about it here.
Let's start by creating a new folder in our components/custom
called editor
with a file called mdx-editor-client.tsx
witht the following code.
1"use client";
2// Note: this is build based on this library: https://mdxeditor.dev/editor/demo
3import "@mdxeditor/editor/style.css";
4import "./editor.css";
5import { cn } from "@/lib/utils";
6
7import {
8 headingsPlugin,
9 listsPlugin,
10 quotePlugin,
11 thematicBreakPlugin,
12 toolbarPlugin,
13 MDXEditor,
14 type MDXEditorMethods,
15 type MDXEditorProps,
16 ConditionalContents,
17 Separator,
18 ChangeCodeMirrorLanguage,
19 UndoRedo,
20 BoldItalicUnderlineToggles,
21 markdownShortcutPlugin,
22 ListsToggle,
23 CreateLink,
24 InsertTable,
25 InsertThematicBreak,
26 InsertCodeBlock,
27 linkPlugin,
28 imagePlugin,
29 codeBlockPlugin,
30 tablePlugin,
31 linkDialogPlugin,
32 codeMirrorPlugin,
33 diffSourcePlugin,
34 CodeToggle,
35 BlockTypeSelect,
36} from "@mdxeditor/editor";
37import { basicLight } from "cm6-theme-basic-light";
38import { useTheme } from "next-themes";
39import type { ForwardedRef } from "react";
40export default function MDXEditorClient({
41 editorRef,
42 ...props
43}: { editorRef: ForwardedRef<MDXEditorMethods> | null } & MDXEditorProps) {
44 const { resolvedTheme } = useTheme();
45 const theme = [basicLight];
46 return (
47 <div
48 className={cn(
49 "min-h-[350px] rounded-md border background-light500_dark200 text-light-700_dark300 light-border-2 w-full dark-editor markdown-editor",
50 props.className
51 )}
52 >
53 <MDXEditor
54 key={resolvedTheme}
55 plugins={[
56 headingsPlugin(),
57 listsPlugin(),
58 linkPlugin(),
59 linkDialogPlugin(),
60 quotePlugin(),
61 thematicBreakPlugin(),
62 markdownShortcutPlugin(),
63 tablePlugin(),
64 imagePlugin(),
65 codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
66 codeMirrorPlugin({
67 codeBlockLanguages: {
68 css: "css",
69 txt: "txt",
70 sql: "sql",
71 html: "html",
72 saas: "saas",
73 scss: "scss",
74 bash: "bash",
75 json: "json",
76 js: "javascript",
77 ts: "typescript",
78 "": "unspecified",
79 tsx: "TypeScript (React)",
80 jsx: "JavaScript (React)",
81 },
82 autoLoadLanguageSupport: true,
83 codeMirrorExtensions: theme,
84 }),
85 diffSourcePlugin({ viewMode: "rich-text", diffMarkdown: "" }),
86 toolbarPlugin({
87 toolbarContents: () => (
88 <ConditionalContents
89 options={[
90 {
91 when: (editor) => editor?.editorType === "codeblock",
92 contents: () => <ChangeCodeMirrorLanguage />,
93 },
94 {
95 fallback: () => (
96 <>
97 <UndoRedo />
98
99 <Separator />
100 <BoldItalicUnderlineToggles />
101 <CodeToggle />
102
103 <Separator />
104 <BlockTypeSelect />
105
106 <Separator />
107 <CreateLink />
108
109 <Separator />
110 <ListsToggle />
111
112 <Separator />
113 <InsertTable />
114 <InsertThematicBreak />
115
116 <Separator />
117 <InsertCodeBlock />
118 </>
119 ),
120 },
121 ]}
122 />
123 ),
124 }),
125 ]}
126 {...props}
127 ref={editorRef}
128 />
129 </div>
130 );
131}
Now let's install the following packages:
yarn add @mdxeditor/editor
# and
yarn add cm6-theme-basic-light
Notice this require custom CSS, let's go ahead and add this now. Create a new file called editor.css
and add the following:
1/* @import "@mdxeditor/editor/style.css"; */
2@import url("@radix-ui/colors/tomato-dark.css");
3@import url("@radix-ui/colors/mauve-dark.css");
4
5.markdown-editor {
6}
7
8/* Force MDX editor to respect container height and enable internal scrolling */
9.mdxeditor-popup-container._editorRoot_1e2ox_53 {
10 height: 100% !important;
11 max-height: 100% !important;
12 overflow-y: auto !important;
13}
14
15/* Ensure the editor content area scrolls properly */
16.mdxeditor-popup-container._editorRoot_1e2ox_53 .w-full {
17 height: 100% !important;
18 overflow-y: auto !important;
19}
20
21.dark .dark-editor {
22 --accentBase: var(--tomato-1);
23 --accentBgSubtle: var(--tomato-2);
24 --accentBg: var(--tomato-3);
25 --accentBgHover: var(--tomato-4);
26 --accentBgActive: var(--tomato-5);
27 --accentLine: var(--tomato-6);
28 --accentBorder: var(--tomato-7);
29 --accentBorderHover: var(--tomato-8);
30 --accentSolid: var(--tomato-9);
31 --accentSolidHover: var(--tomato-10);
32 --accentText: var(--tomato-11);
33 --accentTextContrast: var(--tomato-12);
34
35 --baseBase: var(--mauve-1);
36 --baseBgSubtle: var(--mauve-2);
37 --baseBg: var(--mauve-3);
38 --baseBgHover: var(--mauve-4);
39 --baseBgActive: var(--mauve-5);
40 --baseLine: var(--mauve-6);
41 --baseBorder: var(--mauve-7);
42 --baseBorderHover: var(--mauve-8);
43 --baseSolid: var(--mauve-9);
44 --baseSolidHover: var(--mauve-10);
45 --baseText: var(--mauve-11);
46 --baseTextContrast: var(--mauve-12);
47
48 --admonitionTipBg: var(--cyan4);
49 --admonitionTipBorder: var(--cyan8);
50
51 --admonitionInfoBg: var(--grass4);
52 --admonitionInfoBorder: var(--grass8);
53
54 --admonitionCautionBg: var(--amber4);
55 --admonitionCautionBorder: var(--amber8);
56
57 --admonitionDangerBg: var(--red4);
58 --admonitionDangerBorder: var(--red8);
59
60 --admonitionNoteBg: var(--mauve-4);
61 --admonitionNoteBorder: var(--mauve-8);
62
63 font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
64 Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
65 --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
66 "Liberation Mono", "Courier New", monospace;
67
68 color: var(--baseText);
69 --basePageBg: black;
70 background: var(--basePageBg);
71}
Next inside the editor
folder create another file called editor-wrapper.tsx
with the following code:
1"use client";
2
3import type { MDXEditorMethods, MDXEditorProps } from "@mdxeditor/editor";
4import { AlertCircle } from "lucide-react";
5import dynamic from "next/dynamic";
6import { useEffect, useState, forwardRef } from "react";
7
8import { Alert, AlertDescription } from "@/components/ui/alert";
9import { Skeleton } from "@/components/ui/skeleton";
10
11const MDXEditorClient = dynamic(() => import("./mdx-editor-client"), {
12 ssr: false,
13 loading: () => (
14 <div className="min-h-[350px] rounded-md border background-light500_dark200 text-light-700_dark300 p-4">
15 <div className="space-y-3">
16 <Skeleton className="h-8 w-full" />
17 <Skeleton className="h-4 w-3/4" />
18 <Skeleton className="h-4 w-1/2" />
19 <Skeleton className="h-4 w-5/6" />
20 <div className="space-y-2 pt-4">
21 <Skeleton className="h-4 w-full" />
22 <Skeleton className="h-4 w-full" />
23 <Skeleton className="h-4 w-2/3" />
24 </div>
25 </div>
26 </div>
27 ),
28}) as React.ComponentType<
29 MDXEditorProps & { editorRef?: React.Ref<MDXEditorMethods> }
30>;
31
32interface EditorWrapperProps {
33 markdown?: string;
34 onChange?: (markdown: string) => void;
35 className?: string;
36}
37
38export function EditorWrapper({
39 markdown = "",
40 onChange,
41 className,
42}: EditorWrapperProps) {
43 const [hasError, setHasError] = useState(false);
44
45 useEffect(() => {
46 if (typeof window !== "undefined") {
47 setHasError(false);
48 }
49 }, []);
50
51 if (hasError) {
52 return (
53 <Alert
54 variant="destructive"
55 className="min-h-[350px] flex items-center justify-center"
56 >
57 <AlertCircle className="h-4 w-4" />
58 <AlertDescription>
59 Failed to load the editor. Please refresh the page to try again.
60 </AlertDescription>
61 </Alert>
62 );
63 }
64
65 return (
66 <MDXEditorClient
67 markdown={markdown}
68 onChange={onChange}
69 className={className}
70 />
71 );
72}
73
74export const ForwardRefEditor = forwardRef<MDXEditorMethods, MDXEditorProps>(
75 (props, ref) => <MDXEditorClient {...props} editorRef={ref} />
76);
77
78ForwardRefEditor.displayName = "ForwardRefEditor";
79
80export default EditorWrapper;
claude explain the aboce snippet and why we need to do this in next js
We are using alert component from shadcn ui, so let's add it with the following:
npx shadcn@latest add alert
Finally let's create an index.ts
inside our editor
folder and add the following export:
1"use client";
2import { useState } from "react";
3import { EditorWrapper } from "@/components/custom/editor/editor-wrapper";
4import type { TSummary } from "@/types";
5
6import { Input } from "@/components/ui/input";
7import { SubmitButton } from "@/components/custom/submit-button";
8import { DeleteButton } from "@/components/custom/delete-button";
9
10interface ISummaryUpdateFormProps {
11 summary: TSummary;
12}
13
14const styles = {
15 container: "flex flex-col px-2 py-0.5 relative",
16 titleInput: "mb-3",
17 editor: "h-[calc(100vh-215px)] overflow-y-auto",
18 buttonContainer: "mt-3",
19 updateButton: "inline-block",
20 deleteFormContainer: "absolute bottom-0 right-2",
21 deleteButton: "bg-pink-500 hover:bg-pink-600",
22};
23
24export function SummaryUpdateForm({ summary }: ISummaryUpdateFormProps) {
25 const [content, setContent] = useState(summary.content);
26
27 return (
28 <div className={styles.container}>
29 <form>
30 <Input
31 id="title"
32 name="title"
33 type="text"
34 placeholder={"Title"}
35 defaultValue={summary.title || ""}
36 className={styles.titleInput}
37 />
38
39 <input type="hidden" name="content" value={content} />
40
41 <div>
42 <EditorWrapper
43 markdown={summary.content}
44 onChange={setContent}
45 className={styles.editor}
46 />
47 </div>
48
49 <div className={styles.buttonContainer}>
50 <div className={styles.updateButton}>
51 <SubmitButton
52 text="Update Summary"
53 loadingText="Updating Summary"
54 />
55 </div>
56 </div>
57 </form>
58
59 <div className={styles.deleteFormContainer}>
60 <form onSubmit={() => console.log("DELETE FORM SUBMITTED")}>
61 <DeleteButton className={styles.deleteButton} />
62 </form>
63 </div>
64 </div>
65 );
66}
Nice.
Also, notice that we are using a new component, DeleteButton. Let's create it inside our components/custom
folder. Create a delete-button.tsx
file and add the following code.
1"use client";
2import { useFormStatus } from "react-dom";
3import { cn } from "@/lib/utils";
4import { Button } from "@/components/ui/button";
5import { TrashIcon } from "lucide-react";
6import { Loader2 } from "lucide-react";
7
8function Loader() {
9 return (
10 <div className="flex items-center">
11 <Loader2 className="h-4 w-4 animate-spin" />
12 </div>
13 );
14}
15
16interface DeleteButtonProps {
17 className?: string;
18}
19
20export function DeleteButton({ className }: Readonly<DeleteButtonProps>) {
21 const status = useFormStatus();
22 return (
23 <Button
24 type="submit"
25 aria-disabled={status.pending}
26 disabled={status.pending}
27 className={cn(className)}
28 >
29 {status.pending ? <Loader /> : <TrashIcon className="w-4 h-4" />}
30 </Button>
31 );
32}
Now let's uncomment our SummaryUpdateForm component and see if our content shows up.
The completed code should look like the following:
1import { Params } from "@/types";
2import { loaders } from "@/data/loaders";
3import { extractYouTubeID } from "@/lib/utils";
4import { validateApiResponse } from "@/lib/error-handler";
5import { notFound } from "next/navigation";
6import { YouTubePlayer } from "@/components/custom/youtube-player";
7import { SummaryUpdateForm } from "@/components/forms/summary-update-form"
8
9interface IPageProps {
10 params: Params;
11}
12
13export default async function SummarySingleRoute({ params }: IPageProps) {
14 const resolvedParams = await params;
15 const documentId = resolvedParams?.documentId;
16
17 if (!documentId) notFound();
18
19 const data = await loaders.getSummaryByDocumentId(documentId);
20 const summary = validateApiResponse(data, "summary");
21 const videoId = extractYouTubeID(summary.videoId);
22
23 return (
24 <div className="h-screen overflow-hidden">
25 <div className="h-full grid gap-4 grid-cols-5 p-4">
26 <div className="col-span-3 h-full">
27 <pre>Document Id: {documentId}</pre>
28 <SummaryUpdateForm summary={summary}/>
29 </div>
30 <div className="col-span-2">
31 <div>
32 {videoId ? (
33 <YouTubePlayer videoId={videoId} />
34 ) : (
35 <p>Invalid video URL</p>
36 )}
37 <h1 className="text-2xl font-bold mt-4">{summary.title}</h1>
38 </div>
39 </div>
40 </div>
41 </div>
42 );
43}
Now, navigate to our single summary page and you should see the following.
Great! Now that we've created all our basic components to display summaries, let's test the complete flow.
Before working on form submission for updates and deletes, let's fix an important issue: summaries aren't currently associated with the users who created them.
Using Strapi Route Middleware To Set User/Summary Relation
We'll set the summary/user relationship on the backend, where we can securely verify the logged-in user.
This prevents malicious users from spoofing user IDs from the frontend.
We'll also handle credit deduction in the middleware.
What is Route Middleware?
Route middleware in Strapi has a more limited scope than global middleware. It controls request flow and can modify requests before they proceed.
Route middleware can control access and perform additional logic. For example, it can modify the request context before passing it to Strapi's core elements.
You can learn more about route middlewares here.
Let's create our route middleware using the Strapi CLI. In your backend
folder, run:
yarn strapi generate
Choose to generate a middleware option.
➜ backend git:(main) ✗ yarn strapi generate
yarn run v1.22.22
$ strapi generate
? Strapi Generators
api - Generate a basic API
controller - Generate a controller for an API
content-type - Generate a content type for an API
policy - Generate a policy for an API
❯ middleware - Generate a middleware for an API
migration - Generate a migration
service - Generate a service for an API
We'll call it on-summary-create
and add it to the existing summary
API.
? Strapi Generators middleware - Generate a middleware for an API
? Middleware name on-summary-create
? Where do you want to add this middleware?
Add middleware to root of project
❯ Add middleware to an existing API
Add middleware to an existing plugin
$ strapi generate
? Strapi Generators middleware - Generate a middleware for an API
? Middleware name on-summary-create
? Where do you want to add this middleware? Add middleware to an existing API
? Which API is this for?
global
home-page
❯ summary
Now, let's take a look in the following folder: backend/src/api/summary/middlewares.
You should see the following file: on-summary-create
with the following boilerplate.
1/**
2 * `on-summary-create` middleware
3 */
4
5import type { Core } from "@strapi/strapi";
6
7export default (config, { strapi }: { strapi: Core.Strapi }) => {
8 // Add your own logic here.
9 return async (ctx, next) => {
10 strapi.log.info("In on-summary-create middleware.");
11
12 await next();
13 };
14};
Let's update it with the following code.
1/**
2 * `on-summary-create` middleware
3 */
4
5import type { Core } from "@strapi/strapi";
6
7export default (config, { strapi }: { strapi: Core.Strapi }) => {
8 return async (ctx, next) => {
9 const user = ctx.state.user;
10 if (!user) return ctx.unauthorized("You are not authenticated");
11
12 const availableCredits = user.credits;
13 if (availableCredits === 0)
14 return ctx.unauthorized("You do not have enough credits.");
15
16 console.log("############ Inside middleware end #############");
17
18 // ADD THE AUTHOR ID TO THE BODY
19 const modifiedBody = {
20 ...ctx.request.body,
21 data: {
22 ...ctx.request.body.data,
23 userId: ctx.state.user.documentId,
24 },
25 };
26
27 ctx.request.body = modifiedBody;
28
29 await next();
30
31 // UPDATE THE USER'S CREDITS
32 try {
33 await strapi.documents("plugin::users-permissions.user").update({
34 documentId: user.documentId,
35 data: {
36 credits: availableCredits - 1,
37 },
38 });
39 } catch (error) {
40 ctx.badRequest("Error Updating User Credits");
41 }
42
43 console.log("############ Inside middleware end #############");
44 };
45};
In the code above, we add the userId to the body and deduct one credit from the user.
Before testing it out, we have to enable it inside our route.
You can learn more about Strapi's routes here.
Navigate to the backend/src/api/summary/routes/summary.js
file and update with the following.
1/**
2 * summary router
3 */
4
5import { factories } from "@strapi/strapi";
6
7export default factories.createCoreRouter("api::summary.summary", {
8 config: {
9 create: {
10 middlewares: ["api::summary.on-summary-create"],
11 },
12 },
13});
Now, our middleware will fire when we create a new summary.
Now, restart your Strapi backend and Next.js frontend and create a new summary.
You will see that we are now setting our user data.
Conclusion
In this tutorial, we built a complete video summary feature using OpenAI and the Vercel AI SDK. Here's what we accomplished:
- Created a
SummaryForm
component with YouTube URL validation - Built Next.js API routes for transcript fetching and AI summarization
- Implemented credit-based access control
- Created a summary listing page with card-based layout
- Built dynamic routes for individual summary pages
- Added Strapi middleware for user association and credit deduction
In the next post, we'll add update/delete functionality and implement policies to ensure users can only modify their own content.
This series demonstrates how modern web development combines multiple technologies to create powerful, AI-enhanced applications.
If you have any questions, feel free to stop by at our Discord Community for our daily "open office hours" from 12:30 PM CST to 1:30 PM CST.
Feel free to make PRs to fix any issues you find in the project, or let me know if you have any questions.
Happy coding!
Paul